{{ f.title }}
- On list: {{ f.list.name }}
+ On list: {{ f.list.name }}
Assigned to: {% if f.assigned_to %}{{ f.assigned_to }}{% else %}Anyone{% endif %} (created by: {{ f.created_by }})
Complete: {{ f.completed|yesno:"Yes,No" }}
diff --git a/todo/templates/todo/view_list.html b/todo/templates/todo/view_list.html
index 1a27d38..d85b31c 100644
--- a/todo/templates/todo/view_list.html
+++ b/todo/templates/todo/view_list.html
@@ -9,7 +9,7 @@
// data in a list. We pass that list as an object called "data" to a Django view
// to save the re-ordered data into the database.
- $.post("{% url 'todo-reorder_tasks' %}", data, "json");
+ $.post("{% url 'todo:reorder_tasks' %}", data, "json");
return false;
};
@@ -98,7 +98,7 @@
{% for task in task_list %}
- In list:{{ task.list }}
+ In list:{{ task.list }} Assigned to: {% if task.assigned_to %}{{ task.assigned_to.get_full_name }}{% else %}Anyone{% endif %} Created by: {{ task.created_by.first_name }} {{ task.created_by.last_name }} Due date: {{ task.due_date }}
@@ -48,7 +48,7 @@
List:
-
{{ form.list }}
+
{{ form.task_list }}
diff --git a/todo/views.py b/todo/views.py
index 61955c7..67bbc7a 100644
--- a/todo/views.py
+++ b/todo/views.py
@@ -14,9 +14,9 @@ from django.urls import reverse
from django.views.decorators.csrf import csrf_exempt
from todo import settings
-from todo.forms import AddListForm, AddItemForm, EditItemForm, AddExternalItemForm, SearchForm
-from todo.models import Item, List, Comment
-from todo.utils import mark_done, undo_completed_task, del_tasks, send_notify_mail
+from todo.forms import AddTaskListForm, AddItemForm, EditItemForm, AddExternalItemForm, SearchForm
+from todo.models import Item, TaskList, Comment
+from todo.utils import toggle_done, toggle_deleted, send_notify_mail
def check_user_allowed(user):
@@ -43,9 +43,9 @@ def list_lists(request):
# Superusers see all lists
if request.user.is_superuser:
- list_list = List.objects.all().order_by('group', 'name')
+ list_list = TaskList.objects.all().order_by('group', 'name')
else:
- list_list = List.objects.filter(group__in=request.user.groups.all()).order_by('group', 'name')
+ list_list = TaskList.objects.filter(group__in=request.user.groups.all()).order_by('group', 'name')
list_count = list_list.count()
@@ -53,7 +53,7 @@ def list_lists(request):
if request.user.is_superuser:
item_count = Item.objects.filter(completed=0).count()
else:
- item_count = Item.objects.filter(completed=0).filter(list__group__in=request.user.groups.all()).count()
+ item_count = Item.objects.filter(completed=0).filter(task_list__group__in=request.user.groups.all()).count()
return render(request, 'todo/list_lists.html', locals())
@@ -62,68 +62,47 @@ def list_lists(request):
def del_list(request, list_id, list_slug):
"""Delete an entire list. Danger Will Robinson! Only staff members should be allowed to access this view.
"""
- list = get_object_or_404(List, slug=list_slug)
+ task_list = get_object_or_404(TaskList, slug=list_slug)
if request.method == 'POST':
- List.objects.get(id=list.id).delete()
- messages.success(request, "{list_name} is gone.".format(list_name=list.name))
+ TaskList.objects.get(id=task_list.id).delete()
+ messages.success(request, "{list_name} is gone.".format(list_name=task_list.name))
return redirect('todo:lists')
else:
- item_count_done = Item.objects.filter(list=list.id, completed=1).count()
- item_count_undone = Item.objects.filter(list=list.id, completed=0).count()
- item_count_total = Item.objects.filter(list=list.id).count()
+ item_count_done = Item.objects.filter(task_list=task_list.id, completed=True).count()
+ item_count_undone = Item.objects.filter(task_list=task_list.id, completed=False).count()
+ item_count_total = Item.objects.filter(task_list=task_list.id).count()
return render(request, 'todo/del_list.html', locals())
-@user_passes_test(check_user_allowed)
def list_detail(request, list_id=None, list_slug=None, view_completed=False):
- """Display and manage items in a list.
+ """Display and manage items in a todo list.
"""
- # Make sure the accessing user has permission to view this list.
- # Always authorize the "mine" view. Admins can view/edit all lists.
- if list_slug == "mine" or list_slug == "recent-add" or list_slug == "recent-complete":
- auth_ok = True
- else:
- list = get_object_or_404(List, id=list_id)
- if list.group in request.user.groups.all() or request.user.is_staff or list_slug == "mine":
- auth_ok = True
- else: # User does not belong to the group this list is attached to
- messages.error(request, "You do not have permission to view/edit this list.")
+ if request.POST:
+ # Process completed and deleted requests on each POST
+ toggle_done(request, request.POST.getlist('toggle_done_tasks'))
+ toggle_deleted(request, request.POST.getlist('toggle_deleted_tasks'))
- # Process all possible list interactions on each submit
- mark_done(request, request.POST.getlist('mark_done'))
- del_tasks(request, request.POST.getlist('del_tasks'))
- undo_completed_task(request, request.POST.getlist('undo_completed_task'))
-
- thedate = datetime.datetime.now()
- created_date = "%s-%s-%s" % (thedate.year, thedate.month, thedate.day)
-
- # Get set of items with this list ID, or filter on items assigned to me, or recently added/completed
if list_slug == "mine":
- task_list = Item.objects.filter(assigned_to=request.user, completed=False)
- completed_list = Item.objects.filter(assigned_to=request.user, completed=True)
-
- elif list_slug == "recent-add":
- # Only show items in lists that are in groups that the current user is also in.
- # Assume this only includes uncompleted items.
- task_list = Item.objects.filter(
- list__group__in=(request.user.groups.all()),
- completed=False).order_by('-created_date')[:50]
-
- elif list_slug == "recent-complete":
- # Only show items in lists that are in groups that the current user is also in.
- task_list = Item.objects.filter(
- list__group__in=request.user.groups.all(),
- completed=True).order_by('-completed_date')[:50]
-
+ items = Item.objects.filter(assigned_to=request.user)
else:
- task_list = Item.objects.filter(list=list.id, completed=0)
- completed_list = Item.objects.filter(list=list.id, completed=1)
+ task_list = get_object_or_404(TaskList, id=list_id)
+ items = Item.objects.filter(task_list=task_list.id)
+
+ # Apply filters to base queryset
+ if view_completed:
+ items = items.filter(completed=True)
+ else:
+ items = items.filter(completed=False)
+
+ # ######################
+ # Add New Task Form
+ # ######################
if request.POST.getlist('add_task'):
- form = AddItemForm(list, request.POST, initial={
+ form = AddItemForm(task_list, request.POST, initial={
'assigned_to': request.user.id,
'priority': 999,
})
@@ -140,7 +119,7 @@ def list_detail(request, list_id=None, list_slug=None, view_completed=False):
else:
# Don't allow adding new tasks on some views
if list_slug != "mine" and list_slug != "recent-add" and list_slug != "recent-complete":
- form = AddItemForm(list, initial={
+ form = AddItemForm(task_list=task_list, initial={
'assigned_to': request.user.id,
'priority': 999,
})
@@ -159,7 +138,7 @@ def task_detail(request, task_id):
# Get the group this task belongs to, and check whether current user is a member of that group.
# Admins can edit all tasks.
- if task.list.group in request.user.groups.all() or request.user.is_staff:
+ if task.task_list.group in request.user.groups.all() or request.user.is_staff:
auth_ok = True
if request.POST:
@@ -201,7 +180,7 @@ def task_detail(request, task_id):
messages.success(request, "The task has been edited.")
- return redirect('todo:list_detail', list_id=task.list.id, list_slug=task.list.slug)
+ return redirect('todo:list_detail', list_id=task.task_list.id, list_slug=task.task_list.slug)
else:
form = EditItemForm(instance=task)
if task.due_date:
@@ -276,7 +255,7 @@ def add_list(request):
"""Allow users to add a new todo list to the group they're in.
"""
if request.POST:
- form = AddListForm(request.user, request.POST)
+ form = AddTaskListForm(request.user, request.POST)
if form.is_valid():
try:
form.save()
@@ -290,9 +269,9 @@ def add_list(request):
"Most likely a list with the same name in the same group already exists.")
else:
if request.user.groups.all().count() == 1:
- form = AddListForm(request.user, initial={"group": request.user.groups.all()[0]})
+ form = AddTaskListForm(request.user, initial={"group": request.user.groups.all()[0]})
else:
- form = AddListForm(request.user)
+ form = AddTaskListForm(request.user)
return render(request, 'todo/add_list.html', locals())
From fc4fa7b7f3db503be629ec73b369b2ae306a7113 Mon Sep 17 00:00:00 2001
From: Scot Hacker
Date: Sat, 10 Feb 2018 00:25:56 -0800
Subject: [PATCH 012/210] Move JS to bottom of list_detail
---
todo/templates/todo/list_detail.html | 295 ++++++++++++---------------
1 file changed, 129 insertions(+), 166 deletions(-)
diff --git a/todo/templates/todo/list_detail.html b/todo/templates/todo/list_detail.html
index ccd2c53..1d8ac3f 100644
--- a/todo/templates/todo/list_detail.html
+++ b/todo/templates/todo/list_detail.html
@@ -1,179 +1,142 @@
{% extends "todo/base.html" %}
-{% block title %}Todo List: {{ list.name }}{% endblock %}
+{% block title %}Todo List: {{ task_list.name }}{% endblock %}
{% block content %}
-
-
- {% if list_slug == "mine" %}
-
Tasks assigned to {{ request.user }}
- {% elif auth_ok %}
-
Tasks filed under "{{ list.name }}"
-
This list belongs to group {{ list.group }}
+ {# fixme: convert to bs buttons #}
+ {% if list_slug != "mine" %}
+ {% if view_completed %}
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/todo/views.py b/todo/views.py
index 358a5eb..885c2d8 100644
--- a/todo/views.py
+++ b/todo/views.py
@@ -5,6 +5,7 @@ from django.contrib.auth.decorators import user_passes_test, login_required
from django.contrib.auth.models import User
from django.contrib.sites.models import Site
from django.core.mail import send_mail
+from django.core.exceptions import PermissionDenied
from django.db import IntegrityError
from django.db.models import Q
from django.http import HttpResponse
@@ -21,8 +22,10 @@ from todo.utils import toggle_done, toggle_deleted, send_notify_mail
def check_user_allowed(user):
"""
- Conditions for user_passes_test decorator.
+ Verifies user is logged in, and in staff if that setting is enabled.
+ Per-object permission checks (e.g. to view a particular list) must be in the views that handle those objects.
"""
+
if settings.STAFF_ONLY:
return user.is_authenticated and user.is_staff
else:
@@ -64,6 +67,11 @@ def del_list(request, list_id, list_slug):
"""
task_list = get_object_or_404(TaskList, slug=list_slug)
+ # Ensure user has permission to delete list. Admins can delete all lists.
+ # Get the group this list belongs to, and check whether current user is a member of that group.
+ if task_list.group not in request.user.groups.all() or not request.user.is_staff:
+ raise PermissionDenied
+
if request.method == 'POST':
TaskList.objects.get(id=task_list.id).delete()
messages.success(request, "{list_name} is gone.".format(list_name=task_list.name))
@@ -80,6 +88,13 @@ def list_detail(request, list_id=None, list_slug=None, view_completed=False):
"""Display and manage items in a todo list.
"""
+ task_list = get_object_or_404(TaskList, id=list_id, slug=list_slug)
+
+ # Ensure user has permission to view list. Admins can view all lists.
+ # Get the group this task_list belongs to, and check whether current user is a member of that group.
+ if task_list.group not in request.user.groups.all() and not request.user.is_staff:
+ raise PermissionDenied
+
if request.POST:
# Process completed and deleted requests on each POST
toggle_done(request, request.POST.getlist('toggle_done_tasks'))
@@ -134,50 +149,49 @@ def task_detail(request, task_id):
task = get_object_or_404(Item, pk=task_id)
comment_list = Comment.objects.filter(task=task_id)
- # Ensure user has permission to view item. Admins can edit all tasks.
+ # Ensure user has permission to view item. Admins can view all tasks.
# Get the group this task belongs to, and check whether current user is a member of that group.
+ if task.task_list.group not in request.user.groups.all() and not request.user.is_staff:
+ raise PermissionDenied
- if task.task_list.group in request.user.groups.all() or request.user.is_staff:
- auth_ok = True
+ if request.POST:
+ form = EditItemForm(request.POST, instance=task)
- if request.POST:
- form = EditItemForm(request.POST, instance=task)
+ if form.is_valid():
+ form.save()
- if form.is_valid():
- form.save()
+ # Also save submitted comment, if non-empty
+ if request.POST['comment-body']:
+ c = Comment(
+ author=request.user,
+ task=task,
+ body=request.POST['comment-body'],
+ )
+ c.save()
- # Also save submitted comment, if non-empty
- if request.POST['comment-body']:
- c = Comment(
- author=request.user,
- task=task,
- body=request.POST['comment-body'],
- )
- c.save()
+ # And email comment to all people who have participated in this thread.
+ current_site = Site.objects.get_current()
+ email_subject = render_to_string("todo/email/assigned_subject.txt", {'task': task})
+ email_body = render_to_string(
+ "todo/email/newcomment_body.txt",
+ {'task': task, 'body': request.POST['comment-body'], 'site': current_site, 'user': request.user}
+ )
- # And email comment to all people who have participated in this thread.
- current_site = Site.objects.get_current()
- email_subject = render_to_string("todo/email/assigned_subject.txt", {'task': task})
- email_body = render_to_string(
- "todo/email/newcomment_body.txt",
- {'task': task, 'body': request.POST['comment-body'], 'site': current_site, 'user': request.user}
- )
+ # Get list of all thread participants - everyone who has commented on it plus task creator.
+ commenters = Comment.objects.filter(task=task)
+ recip_list = [c.author.email for c in commenters]
+ recip_list.append(task.created_by.email)
+ recip_list = list(set(recip_list)) # Eliminate duplicates
- # Get list of all thread participants - everyone who has commented on it plus task creator.
- commenters = Comment.objects.filter(task=task)
- recip_list = [c.author.email for c in commenters]
- recip_list.append(task.created_by.email)
- recip_list = list(set(recip_list)) # Eliminate duplicates
+ try:
+ send_mail(email_subject, email_body, task.created_by.email, recip_list, fail_silently=False)
+ messages.success(request, "Comment sent to thread participants.")
+ except ConnectionRefusedError:
+ messages.error(request, "Comment saved but mail not sent. Contact your administrator.")
- try:
- send_mail(email_subject, email_body, task.created_by.email, recip_list, fail_silently=False)
- messages.success(request, "Comment sent to thread participants.")
- except ConnectionRefusedError:
- messages.error(request, "Comment saved but mail not sent. Contact your administrator.")
+ messages.success(request, "The task has been edited.")
- messages.success(request, "The task has been edited.")
-
- return redirect('todo:list_detail', list_id=task.task_list.id, list_slug=task.task_list.slug)
+ return redirect('todo:list_detail', list_id=task.task_list.id, list_slug=task.task_list.slug)
else:
form = EditItemForm(instance=task)
if task.due_date:
From 1c2a247f8317a3cc75765ad355a4df5aacbd2951 Mon Sep 17 00:00:00 2001
From: Scot Hacker
Date: Sun, 11 Feb 2018 00:44:16 -0800
Subject: [PATCH 018/210] Remove errant permission note
---
todo/views.py | 2 --
1 file changed, 2 deletions(-)
diff --git a/todo/views.py b/todo/views.py
index 885c2d8..f9d3f76 100644
--- a/todo/views.py
+++ b/todo/views.py
@@ -198,8 +198,6 @@ def task_detail(request, task_id):
thedate = task.due_date
else:
thedate = datetime.datetime.now()
- else:
- messages.info(request, "You do not have permission to view/edit this task.")
return render(request, 'todo/task_detail.html', locals())
From 5c1d98d952e300aa275c74a56e0b4f107eb9750d Mon Sep 17 00:00:00 2001
From: Scot Hacker
Date: Sun, 11 Feb 2018 00:48:19 -0800
Subject: [PATCH 019/210] Improve display of completed status
---
todo/templates/todo/task_detail.html | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/todo/templates/todo/task_detail.html b/todo/templates/todo/task_detail.html
index f1d94ca..c6983db 100644
--- a/todo/templates/todo/task_detail.html
+++ b/todo/templates/todo/task_detail.html
@@ -45,7 +45,7 @@
{{ task.due_date }} Completed:
- {{ form.completed }}
+ {{ form.completed|yesno:"Yes,No" }}
{% if task.note %}
From ad33d62024401acbc840067d3ce564e8f26f130b Mon Sep 17 00:00:00 2001
From: Scot Hacker
Date: Sun, 11 Feb 2018 01:06:43 -0800
Subject: [PATCH 020/210] Fix display of EditItemForm
---
todo/views.py | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/todo/views.py b/todo/views.py
index f9d3f76..f44d8dc 100644
--- a/todo/views.py
+++ b/todo/views.py
@@ -192,12 +192,12 @@ def task_detail(request, task_id):
messages.success(request, "The task has been edited.")
return redirect('todo:list_detail', list_id=task.task_list.id, list_slug=task.task_list.slug)
+ else:
+ form = EditItemForm(instance=task)
+ if task.due_date:
+ thedate = task.due_date
else:
- form = EditItemForm(instance=task)
- if task.due_date:
- thedate = task.due_date
- else:
- thedate = datetime.datetime.now()
+ thedate = datetime.datetime.now()
return render(request, 'todo/task_detail.html', locals())
From 5cbfc554eebe492c30b9917df4fbe0331f60173e Mon Sep 17 00:00:00 2001
From: Scot Hacker
Date: Sun, 11 Feb 2018 22:57:35 -0800
Subject: [PATCH 021/210] Re-enable "mine" tasks
---
todo/templates/todo/list_detail.html | 3 ---
todo/views.py | 11 ++++++-----
2 files changed, 6 insertions(+), 8 deletions(-)
diff --git a/todo/templates/todo/list_detail.html b/todo/templates/todo/list_detail.html
index 1d8ac3f..6ab8c46 100644
--- a/todo/templates/todo/list_detail.html
+++ b/todo/templates/todo/list_detail.html
@@ -7,8 +7,6 @@
{% if list_slug != "mine" %}
- {% endif %}
+
+ {% comment %} Needs form tag {% endcomment %}
Edit Task
@@ -96,7 +96,7 @@
-
+
{% if comment_list %}
From 07e02b56f70a51049e9f4bf578334f52c513c5dd Mon Sep 17 00:00:00 2001
From: Scot Hacker
Date: Sat, 24 Mar 2018 12:24:49 -0700
Subject: [PATCH 042/210] Consolidate forms for task creation and editing
---
todo/forms.py | 23 +---
todo/templates/todo/include/task_edit.html | 54 +++++++++
todo/templates/todo/list_detail.html | 48 +-------
todo/templates/todo/list_lists.html | 1 +
todo/templates/todo/task_detail.html | 129 ++++++---------------
todo/views.py | 50 ++++----
6 files changed, 128 insertions(+), 177 deletions(-)
create mode 100644 todo/templates/todo/include/task_edit.html
diff --git a/todo/forms.py b/todo/forms.py
index bca5925..756e5c8 100644
--- a/todo/forms.py
+++ b/todo/forms.py
@@ -19,16 +19,17 @@ class AddTaskListForm(ModelForm):
exclude = []
-class AddItemForm(ModelForm):
+class AddEditItemForm(ModelForm):
"""The picklist showing the users to which a new task can be assigned
must find other members of the groups the current list belongs to."""
- def __init__(self, task_list, *args, **kwargs):
+ def __init__(self, user, *args, **kwargs):
super().__init__(*args, **kwargs)
- self.fields['assigned_to'].queryset = get_user_model().objects.filter(groups__in=[task_list.group])
+ self.fields['assigned_to'].queryset = get_user_model().objects.filter(groups__in=user.groups.all())
self.fields['assigned_to'].label_from_instance = lambda obj: "%s (%s)" % (obj.get_full_name(), obj.username)
self.fields['assigned_to'].widget.attrs = {
'id': 'id_assigned_to', 'class': "custom-select mb-3", 'name': 'assigned_to'}
+ self.fields['task_list'].value = kwargs['initial']['task_list'].id
due_date = forms.DateField(
widget=forms.DateInput(attrs={'type': 'date'}), required=False)
@@ -44,22 +45,6 @@ class AddItemForm(ModelForm):
exclude = []
-class EditItemForm(ModelForm):
- """The picklist showing the users to which a new task can be assigned
- must find other members of the groups the current list belongs to."""
-
- def __init__(self, *args, **kwargs):
- super(EditItemForm, self).__init__(*args, **kwargs)
- self.fields['assigned_to'].queryset = get_user_model().objects.filter(
- groups__in=[self.instance.task_list.group])
-
- due_date = forms.DateField(widget=forms.DateInput(attrs={'type': 'date'}), required=False)
-
- class Meta:
- model = Item
- exclude = ('created_date', 'created_by',)
-
-
class AddExternalItemForm(ModelForm):
"""Form to allow users who are not part of the GTD system to file a ticket."""
diff --git a/todo/templates/todo/include/task_edit.html b/todo/templates/todo/include/task_edit.html
new file mode 100644
index 0000000..8139c16
--- /dev/null
+++ b/todo/templates/todo/include/task_edit.html
@@ -0,0 +1,54 @@
+{# Form used by both Add Task and Edit Task views #}
+
+
+ {% csrf_token %}
+
+
+
+
+
+
+
+
+
+
+
+ Describe the task or bug. Provide steps to reproduce the issue.
+
+
+
+
+
+
+
+
+
+
+ {# See todo.forms.AddItemForm #}
+ {{form.assigned_to}}
+
+
+
+
+
+
+ Email notifications will only be sent if task is assigned to someone other than yourself.
+
+
- {# See todo.forms.AddItemForm #}
+ {# See todo.forms.AddEditItemForm #}
{{form.assigned_to}}
diff --git a/todo/templates/todo/list_lists.html b/todo/templates/todo/list_lists.html
index 88c0a09..5176325 100644
--- a/todo/templates/todo/list_lists.html
+++ b/todo/templates/todo/list_lists.html
@@ -10,7 +10,7 @@
{% regroup lists by group as section_list %}
{% for group in section_list %}
Group: {{ group.grouper }}
-
+
{% for item in group.list %}
{{ item.name }}
From 0d7a933d1c5ba621d9c88331952f7cb46cc41423 Mon Sep 17 00:00:00 2001
From: Scot Hacker
Date: Sat, 24 Mar 2018 17:16:12 -0700
Subject: [PATCH 048/210] Format process buttons
---
todo/templates/todo/include/toggle_delete.html | 18 +++++++++++-------
1 file changed, 11 insertions(+), 7 deletions(-)
diff --git a/todo/templates/todo/include/toggle_delete.html b/todo/templates/todo/include/toggle_delete.html
index 50d6334..f28def2 100644
--- a/todo/templates/todo/include/toggle_delete.html
+++ b/todo/templates/todo/include/toggle_delete.html
@@ -1,8 +1,12 @@
-{% if list_slug != "mine" %} {% if view_completed %}
- View incomplete tasks
-{% else %}
- View completed tasks
-{% endif %}
-Delete this list
-{% endif %}
\ No newline at end of file
+ {% if list_slug != "mine" %}
+ {% if view_completed %}
+ View incomplete tasks
+
+ {% else %}
+ View completed tasks
+
+ {% endif %}
+
+ Delete this list
+ {% endif %}
From f4d1da0ab72c16afb931868a57bffab7eecefddc Mon Sep 17 00:00:00 2001
From: Scot Hacker
Date: Mon, 26 Mar 2018 00:37:29 -0700
Subject: [PATCH 049/210] Smoke and permissions tests
And various bug / permissions config tweaks to accompany
---
todo/models.py | 2 +-
todo/settings.py | 7 ---
todo/tests/__init__.py | 0
todo/tests/conftest.py | 17 ++++++
todo/tests/test_views.py | 112 +++++++++++++++++++++++++++++++++++++++
todo/utils.py | 15 ------
todo/views.py | 57 +++++++++++---------
7 files changed, 161 insertions(+), 49 deletions(-)
delete mode 100644 todo/settings.py
create mode 100644 todo/tests/__init__.py
create mode 100644 todo/tests/conftest.py
create mode 100644 todo/tests/test_views.py
diff --git a/todo/models.py b/todo/models.py
index 0c81e41..01e4163 100644
--- a/todo/models.py
+++ b/todo/models.py
@@ -56,7 +56,7 @@ class Item(models.Model):
return reverse('todo:task_detail', kwargs={'task_id': self.id, })
# Auto-set the item creation / completed date
- def save(self):
+ def save(self, **kwargs):
# If Item is being marked complete, set the completed_date
if self.completed:
self.completed_date = datetime.datetime.now()
diff --git a/todo/settings.py b/todo/settings.py
deleted file mode 100644
index e9231b5..0000000
--- a/todo/settings.py
+++ /dev/null
@@ -1,7 +0,0 @@
-from django.conf import settings
-
-
-STAFF_ONLY = getattr(settings, 'TODO_STAFF_ONLY', False)
-DEFAULT_LIST_ID = getattr(settings, 'TODO_DEFAULT_LIST_ID', 1)
-DEFAULT_ASSIGNEE = getattr(settings, 'TODO_DEFAULT_ASSIGNEE', None)
-PUBLIC_SUBMIT_REDIRECT = getattr(settings, 'TODO_PUBLIC_SUBMIT_REDIRECT', '/')
diff --git a/todo/tests/__init__.py b/todo/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/todo/tests/conftest.py b/todo/tests/conftest.py
new file mode 100644
index 0000000..747b5db
--- /dev/null
+++ b/todo/tests/conftest.py
@@ -0,0 +1,17 @@
+import pytest
+
+from django.contrib.auth.models import Group
+
+from todo.models import Item, TaskList
+
+
+@pytest.fixture
+def todo_setup(django_user_model):
+ g1 = Group.objects.create(name="Weavers")
+ u1 = django_user_model.objects.create(username="you", password="password")
+ u1.groups.add(g1)
+ tlist = TaskList.objects.create(group=g1, name="Zip", slug="zip")
+
+ Item.objects.create(created_by=u1, title="Task 1", task_list=tlist, priority=1)
+ Item.objects.create(created_by=u1, title="Task 2", task_list=tlist, priority=2)
+ Item.objects.create(created_by=u1, title="Task 3", task_list=tlist, priority=3)
diff --git a/todo/tests/test_views.py b/todo/tests/test_views.py
new file mode 100644
index 0000000..129f656
--- /dev/null
+++ b/todo/tests/test_views.py
@@ -0,0 +1,112 @@
+import pytest
+
+from django.urls import reverse
+
+from todo.models import Item, TaskList
+
+"""
+First the "smoketests" - do they respond at all for a logged in admin user?
+Next permissions tests - some views should respond for staffers only.
+After that, view contents and behaviors.
+"""
+
+# ### SMOKETESTS ###
+
+@pytest.mark.django_db
+def test_todo_setup(todo_setup):
+ assert Item.objects.all().count() == 3
+
+
+def test_view_list_lists(todo_setup, admin_client):
+ url = reverse('todo:lists')
+ response = admin_client.get(url)
+ assert response.status_code == 200
+
+
+def test_view_reorder(todo_setup, admin_client):
+ url = reverse('todo:reorder_tasks')
+ response = admin_client.get(url)
+ assert response.status_code == 201 # Special case return value expected
+
+
+def test_view_external_add(todo_setup, admin_client, settings):
+ default_list = TaskList.objects.first()
+ settings.TODO_DEFAULT_LIST_ID = default_list.id
+ assert settings.TODO_DEFAULT_LIST_ID == default_list.id
+ url = reverse('todo:external_add')
+ response = admin_client.get(url)
+ assert response.status_code == 200
+
+
+def test_view_mine(todo_setup, admin_client):
+ url = reverse('todo:mine')
+ response = admin_client.get(url)
+ assert response.status_code == 200
+
+
+def test_view_list_completed(todo_setup, admin_client):
+ tlist = TaskList.objects.get(slug="zip")
+ url = reverse('todo:list_detail_completed', kwargs={'list_id': tlist.id, 'list_slug': tlist.slug})
+ response = admin_client.get(url)
+ assert response.status_code == 200
+
+
+def test_view_list(todo_setup, admin_client):
+ tlist = TaskList.objects.get(slug="zip")
+ url = reverse('todo:list_detail', kwargs={'list_id': tlist.id, 'list_slug': tlist.slug})
+ response = admin_client.get(url)
+ assert response.status_code == 200
+
+
+def test_del_list(todo_setup, admin_client):
+ tlist = TaskList.objects.get(slug="zip")
+ url = reverse('todo:del_list', kwargs={'list_id': tlist.id, 'list_slug': tlist.slug})
+ response = admin_client.get(url)
+ assert response.status_code == 200
+
+
+def test_view_add_list(todo_setup, admin_client):
+ url = reverse('todo:add_list')
+ response = admin_client.get(url)
+ assert response.status_code == 200
+
+
+def test_view_task_detail(todo_setup, admin_client):
+ task = Item.objects.first()
+ url = reverse('todo:task_detail', kwargs={'task_id': task.id})
+ response = admin_client.get(url)
+ assert response.status_code == 200
+
+
+def test_view_search(todo_setup, admin_client):
+ url = reverse('todo:search')
+ response = admin_client.get(url)
+ assert response.status_code == 200
+
+
+# ### PERMISSIONS ###
+
+"""
+Some views are for staff users only. We've already smoke-tested with Admin user -
+try these with normal user.
+"""
+
+
+def test_view_add_list_nonadmin(todo_setup, client):
+ url = reverse('todo:add_list')
+ client.login(username="you", password="password")
+ response = client.get(url)
+ assert response.status_code == 302 # Redirects to login. Would prefer 403...
+
+
+def test_view_del_list_nonadmin(todo_setup, client):
+ tlist = TaskList.objects.get(slug="zip")
+ url = reverse('todo:del_list', kwargs={'list_id': tlist.id, 'list_slug': tlist.slug})
+ client.login(username="you", password="password")
+ response = client.get(url)
+ assert response.status_code == 302 # Redirects to login. Would prefer 403...
+
+
+# TODO
+# View a task that's not in one of my groups?
+# Mark complete
\ No newline at end of file
diff --git a/todo/utils.py b/todo/utils.py
index c46dc01..8b291ad 100644
--- a/todo/utils.py
+++ b/todo/utils.py
@@ -1,28 +1,13 @@
import datetime
-from django.conf import settings
from django.contrib import messages
-from django.contrib.auth.models import User
from django.contrib.sites.models import Site
from django.core.mail import send_mail
-from django.http import HttpResponse
from django.template.loader import render_to_string
from todo.models import Item, Comment
-def check_user_allowed(user: User) -> HttpResponse:
- """
- Verifies user is logged in, and in staff if that setting is enabled.
- Per-object permission checks (e.g. to view a particular list) are in the views that handle those objects.
- """
-
- if hasattr(settings, "STAFF_ONLY") and getattr(settings, "STAFF_ONLY"):
- return user.is_authenticated and user.is_staff
- else:
- return user.is_authenticated
-
-
def toggle_done(request, items):
"""Check for items in the mark_done POST array. If present, change status to complete.
Takes a list of task IDs. No return value.
diff --git a/todo/views.py b/todo/views.py
index 0a50e00..7f34f50 100644
--- a/todo/views.py
+++ b/todo/views.py
@@ -1,7 +1,9 @@
import datetime
+from django.conf import settings
from django.contrib import messages
-from django.contrib.auth.decorators import user_passes_test, login_required
+from django.contrib.auth.decorators import login_required
+from django.contrib.admin.views.decorators import staff_member_required
from django.contrib.auth.models import User
from django.contrib.sites.models import Site
from django.core.exceptions import PermissionDenied
@@ -13,7 +15,6 @@ from django.shortcuts import get_object_or_404, render, redirect
from django.template.loader import render_to_string
from django.views.decorators.csrf import csrf_exempt
-from todo import settings
from todo.forms import AddTaskListForm, AddEditItemForm, AddExternalItemForm, SearchForm
from todo.models import Item, TaskList, Comment
from todo.utils import (
@@ -21,10 +22,10 @@ from todo.utils import (
toggle_deleted,
send_notify_mail,
send_email_to_thread_participants,
- check_user_allowed)
+ )
-@user_passes_test(check_user_allowed)
+@login_required
def list_lists(request) -> HttpResponse:
"""Homepage view - list of lists a user can view, and ability to add a list.
"""
@@ -61,7 +62,8 @@ def list_lists(request) -> HttpResponse:
return render(request, 'todo/list_lists.html', context)
-@user_passes_test(check_user_allowed)
+@staff_member_required
+@login_required
def del_list(request, list_id: int, list_slug: str) -> HttpResponse:
"""Delete an entire list. Danger Will Robinson! Only staff members should be allowed to access this view.
"""
@@ -70,7 +72,7 @@ def del_list(request, list_id: int, list_slug: str) -> HttpResponse:
# Ensure user has permission to delete list. Admins can delete all lists.
# Get the group this list belongs to, and check whether current user is a member of that group.
# FIXME: This means any group member can delete lists, which is probably too permissive.
- if task_list.group not in request.user.groups.all() or not request.user.is_staff:
+ if task_list.group not in request.user.groups.all() and not request.user.is_staff:
raise PermissionDenied
if request.method == 'POST':
@@ -92,6 +94,7 @@ def del_list(request, list_id: int, list_slug: str) -> HttpResponse:
return render(request, 'todo/del_list.html', context)
+@login_required
def list_detail(request, list_id=None, list_slug=None, view_completed=False):
"""Display and manage items in a todo list.
"""
@@ -163,7 +166,7 @@ def list_detail(request, list_id=None, list_slug=None, view_completed=False):
return render(request, 'todo/list_detail.html', context)
-@user_passes_test(check_user_allowed)
+@login_required
def task_detail(request, task_id: int) -> HttpResponse:
"""View task details. Allow task details to be edited. Process new comments on task.
"""
@@ -219,28 +222,30 @@ def task_detail(request, task_id: int) -> HttpResponse:
@csrf_exempt
-@user_passes_test(check_user_allowed)
+@login_required
def reorder_tasks(request) -> HttpResponse:
"""Handle task re-ordering (priorities) from JQuery drag/drop in list_detail.html
"""
newtasklist = request.POST.getlist('tasktable[]')
- # First item in received list is always empty - remove it
- del newtasklist[0]
+ if newtasklist:
+ # First item in received list is always empty - remove it
+ del newtasklist[0]
- # Re-prioritize each item in list
- i = 1
- for t in newtasklist:
- newitem = Item.objects.get(pk=t)
- newitem.priority = i
- newitem.save()
- i += 1
+ # Re-prioritize each item in list
+ i = 1
+ for t in newtasklist:
+ newitem = Item.objects.get(pk=t)
+ newitem.priority = i
+ newitem.save()
+ i += 1
# 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)
-@user_passes_test(check_user_allowed)
+@staff_member_required
+@login_required
def add_list(request) -> HttpResponse:
"""Allow users to add a new todo list to the group they're in.
"""
@@ -271,7 +276,7 @@ def add_list(request) -> HttpResponse:
return render(request, 'todo/add_list.html', context)
-@user_passes_test(check_user_allowed)
+@login_required
def search(request) -> HttpResponse:
"""Search for tasks user has permission to see.
"""
@@ -318,10 +323,10 @@ def external_add(request) -> HttpResponse:
Publicly filed tickets are unassigned unless settings.DEFAULT_ASSIGNEE exists.
"""
- if not settings.DEFAULT_LIST_ID:
- raise RuntimeError("This feature requires DEFAULT_LIST_ID in settings. See documentation.")
+ if not settings.TODO_DEFAULT_LIST_ID:
+ raise RuntimeError("This feature requires TODO_DEFAULT_LIST_ID: in settings. See documentation.")
- if not TaskList.objects.filter(id=settings.DEFAULT_LIST_ID).exists():
+ if not TaskList.objects.filter(id=settings.TODO_DEFAULT_LIST_ID).exists():
raise RuntimeError("There is no TaskList with ID specified for DEFAULT_LIST_ID in settings.")
if request.POST:
@@ -330,10 +335,10 @@ def external_add(request) -> HttpResponse:
if form.is_valid():
current_site = Site.objects.get_current()
item = form.save(commit=False)
- item.task_list = TaskList.objects.get(id=settings.DEFAULT_LIST_ID)
+ item.task_list = TaskList.objects.get(id=settings.TODO_DEFAULT_LIST_ID)
item.created_by = request.user
- if settings.DEFAULT_ASSIGNEE:
- item.assigned_to = User.objects.get(username=settings.DEFAULT_ASSIGNEE)
+ if settings.TODO_DEFAULT_ASSIGNEE:
+ item.assigned_to = User.objects.get(username=settings.TODO_DEFAULT_ASSIGNEE)
item.save()
# Send email to assignee if we have one
@@ -349,7 +354,7 @@ def external_add(request) -> HttpResponse:
messages.success(request, "Your trouble ticket has been submitted. We'll get back to you soon.")
- return redirect(settings.PUBLIC_SUBMIT_REDIRECT)
+ return redirect(settings.TODO_PUBLIC_SUBMIT_REDIRECT)
else:
form = AddExternalItemForm(initial={'priority': 999})
From dbc379da6a0e7c977cc395f0c3e4c83674b88336 Mon Sep 17 00:00:00 2001
From: Scot Hacker
Date: Mon, 26 Mar 2018 23:49:25 -0700
Subject: [PATCH 050/210] Custom perm decorator to raise 403
---
todo/views.py | 21 ++++++++++++++++++---
1 file changed, 18 insertions(+), 3 deletions(-)
diff --git a/todo/views.py b/todo/views.py
index 7f34f50..bb29d1d 100644
--- a/todo/views.py
+++ b/todo/views.py
@@ -3,7 +3,6 @@ import datetime
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
-from django.contrib.admin.views.decorators import staff_member_required
from django.contrib.auth.models import User
from django.contrib.sites.models import Site
from django.core.exceptions import PermissionDenied
@@ -25,6 +24,22 @@ from todo.utils import (
)
+def staff_only(function):
+ """
+ Custom view decorator allows us to raise 403 on insufficient permissions,
+ rather than redirect user to login view.
+ """
+ def wrap(request, *args, **kwargs):
+ if request.user.is_staff:
+ return function(request, *args, **kwargs)
+ else:
+ raise PermissionDenied
+
+ wrap.__doc__ = function.__doc__
+ wrap.__name__ = function.__name__
+ return wrap
+
+
@login_required
def list_lists(request) -> HttpResponse:
"""Homepage view - list of lists a user can view, and ability to add a list.
@@ -62,7 +77,7 @@ def list_lists(request) -> HttpResponse:
return render(request, 'todo/list_lists.html', context)
-@staff_member_required
+@staff_only
@login_required
def del_list(request, list_id: int, list_slug: str) -> HttpResponse:
"""Delete an entire list. Danger Will Robinson! Only staff members should be allowed to access this view.
@@ -244,7 +259,7 @@ def reorder_tasks(request) -> HttpResponse:
return HttpResponse(status=201)
-@staff_member_required
+@staff_only
@login_required
def add_list(request) -> HttpResponse:
"""Allow users to add a new todo list to the group they're in.
From 9d436674db1303194da5040b236894fad4a57260 Mon Sep 17 00:00:00 2001
From: Scot Hacker
Date: Mon, 26 Mar 2018 23:50:14 -0700
Subject: [PATCH 051/210] Tests for list mine/not mine
---
todo/models.py | 6 +-----
todo/tests/conftest.py | 23 ++++++++++++++++-------
todo/tests/test_views.py | 37 ++++++++++++++++++++++++++++++-------
3 files changed, 47 insertions(+), 19 deletions(-)
diff --git a/todo/models.py b/todo/models.py
index 01e4163..b767b8f 100644
--- a/todo/models.py
+++ b/todo/models.py
@@ -17,15 +17,11 @@ class TaskList(models.Model):
def __str__(self):
return self.name
- def list_detail(self):
- # Count all incomplete tasks on the current list instance
- return Item.objects.filter(task_list=self, completed=0)
-
class Meta:
ordering = ["name"]
verbose_name_plural = "Task Lists"
- # Prevents (at the database level) creation of two lists with the same name in the same group
+ # Prevents (at the database level) creation of two lists with the same slug in the same group
unique_together = ("group", "slug")
diff --git a/todo/tests/conftest.py b/todo/tests/conftest.py
index 747b5db..ba58d89 100644
--- a/todo/tests/conftest.py
+++ b/todo/tests/conftest.py
@@ -7,11 +7,20 @@ from todo.models import Item, TaskList
@pytest.fixture
def todo_setup(django_user_model):
- g1 = Group.objects.create(name="Weavers")
- u1 = django_user_model.objects.create(username="you", password="password")
- u1.groups.add(g1)
- tlist = TaskList.objects.create(group=g1, name="Zip", slug="zip")
+ # Two groups with different users, two sets of tasks.
- Item.objects.create(created_by=u1, title="Task 1", task_list=tlist, priority=1)
- Item.objects.create(created_by=u1, title="Task 2", task_list=tlist, priority=2)
- Item.objects.create(created_by=u1, title="Task 3", task_list=tlist, priority=3)
+ g1 = Group.objects.create(name="Workgroup One")
+ u1 = django_user_model.objects.create_user(username="u1", password="password")
+ u1.groups.add(g1)
+ tlist1 = TaskList.objects.create(group=g1, name="Zip", slug="zip")
+ Item.objects.create(created_by=u1, title="Task 1", task_list=tlist1, priority=1)
+ Item.objects.create(created_by=u1, title="Task 2", task_list=tlist1, priority=2)
+ Item.objects.create(created_by=u1, title="Task 3", task_list=tlist1, priority=3)
+
+ g2 = Group.objects.create(name="Workgroup Two")
+ u2 = django_user_model.objects.create_user(username="u2", password="password")
+ u2.groups.add(g2)
+ tlist2 = TaskList.objects.create(group=g2, name="Zap", slug="zap")
+ Item.objects.create(created_by=u2, title="Task 1", task_list=tlist2, priority=1)
+ Item.objects.create(created_by=u2, title="Task 2", task_list=tlist2, priority=2)
+ Item.objects.create(created_by=u2, title="Task 3", task_list=tlist2, priority=3)
diff --git a/todo/tests/test_views.py b/todo/tests/test_views.py
index 129f656..ee166cc 100644
--- a/todo/tests/test_views.py
+++ b/todo/tests/test_views.py
@@ -1,5 +1,6 @@
import pytest
+from django.contrib.auth import get_user_model
from django.urls import reverse
from todo.models import Item, TaskList
@@ -14,7 +15,7 @@ After that, view contents and behaviors.
@pytest.mark.django_db
def test_todo_setup(todo_setup):
- assert Item.objects.all().count() == 3
+ assert Item.objects.all().count() == 6
def test_view_list_lists(todo_setup, admin_client):
@@ -87,8 +88,8 @@ def test_view_search(todo_setup, admin_client):
# ### PERMISSIONS ###
"""
-Some views are for staff users only. We've already smoke-tested with Admin user -
-try these with normal user.
+Some views are for staff users only.
+We've already smoke-tested with Admin user - try these with normal user.
"""
@@ -96,7 +97,7 @@ def test_view_add_list_nonadmin(todo_setup, client):
url = reverse('todo:add_list')
client.login(username="you", password="password")
response = client.get(url)
- assert response.status_code == 302 # Redirects to login. Would prefer 403...
+ assert response.status_code == 403
def test_view_del_list_nonadmin(todo_setup, client):
@@ -104,9 +105,31 @@ def test_view_del_list_nonadmin(todo_setup, client):
url = reverse('todo:del_list', kwargs={'list_id': tlist.id, 'list_slug': tlist.slug})
client.login(username="you", password="password")
response = client.get(url)
- assert response.status_code == 302 # Redirects to login. Would prefer 403...
+ assert response.status_code == 403
+
+
+def test_view_list_mine(todo_setup, client):
+ """View a list in a group I belong to.
+ """
+ tlist = TaskList.objects.get(slug="zip") # User u1 is in this group's list
+ url = reverse('todo:list_detail', kwargs={'list_id': tlist.id, 'list_slug': tlist.slug})
+ client.login(username="u1", password="password")
+ response = client.get(url)
+ assert response.status_code == 200
+
+
+def test_view_list_not_mine(todo_setup, client):
+ """View a list in a group I don't belong to.
+ """
+ tlist = TaskList.objects.get(slug="zip") # User u1 is in this group, user u2 is not.
+ url = reverse('todo:list_detail', kwargs={'list_id': tlist.id, 'list_slug': tlist.slug})
+ client.login(username="u2", password="password")
+ response = client.get(url)
+ assert response.status_code == 403
+
# TODO
-# View a task that's not in one of my groups?
-# Mark complete
\ No newline at end of file
+# View a task in a list in a group I do / don't belong to.
+# Mark complete
+# staff_only decorator
From 5b2820df37e5a864129aa5919f03e7e1ae1adde1 Mon Sep 17 00:00:00 2001
From: Scot Hacker
Date: Tue, 27 Mar 2018 22:51:39 -0700
Subject: [PATCH 052/210] Add more permissions tests
---
todo/tests/conftest.py | 4 ++--
todo/tests/test_views.py | 39 ++++++++++++++++++++++++++++++++++-----
2 files changed, 36 insertions(+), 7 deletions(-)
diff --git a/todo/tests/conftest.py b/todo/tests/conftest.py
index ba58d89..aca0e88 100644
--- a/todo/tests/conftest.py
+++ b/todo/tests/conftest.py
@@ -14,7 +14,7 @@ def todo_setup(django_user_model):
u1.groups.add(g1)
tlist1 = TaskList.objects.create(group=g1, name="Zip", slug="zip")
Item.objects.create(created_by=u1, title="Task 1", task_list=tlist1, priority=1)
- Item.objects.create(created_by=u1, title="Task 2", task_list=tlist1, priority=2)
+ Item.objects.create(created_by=u1, title="Task 2", task_list=tlist1, priority=2, completed=True)
Item.objects.create(created_by=u1, title="Task 3", task_list=tlist1, priority=3)
g2 = Group.objects.create(name="Workgroup Two")
@@ -22,5 +22,5 @@ def todo_setup(django_user_model):
u2.groups.add(g2)
tlist2 = TaskList.objects.create(group=g2, name="Zap", slug="zap")
Item.objects.create(created_by=u2, title="Task 1", task_list=tlist2, priority=1)
- Item.objects.create(created_by=u2, title="Task 2", task_list=tlist2, priority=2)
+ Item.objects.create(created_by=u2, title="Task 2", task_list=tlist2, priority=2, completed=True)
Item.objects.create(created_by=u2, title="Task 3", task_list=tlist2, priority=3)
diff --git a/todo/tests/test_views.py b/todo/tests/test_views.py
index ee166cc..cc80740 100644
--- a/todo/tests/test_views.py
+++ b/todo/tests/test_views.py
@@ -1,6 +1,6 @@
import pytest
-from django.contrib.auth import get_user_model
+from django.contrib.auth.models import Group
from django.urls import reverse
from todo.models import Item, TaskList
@@ -90,6 +90,7 @@ def test_view_search(todo_setup, admin_client):
"""
Some views are for staff users only.
We've already smoke-tested with Admin user - try these with normal user.
+These exercise our custom @staff_only decorator without calling that function explicitly.
"""
@@ -128,8 +129,36 @@ def test_view_list_not_mine(todo_setup, client):
assert response.status_code == 403
+def test_view_task_mine(todo_setup, client):
+ # Users can always view their own tasks
+ task = Item.objects.filter(created_by__username="u1").first()
+ client.login(username="u1", password="password")
+ url = reverse('todo:task_detail', kwargs={'task_id': task.id})
+ response = client.get(url)
+ assert response.status_code == 200
-# TODO
-# View a task in a list in a group I do / don't belong to.
-# Mark complete
-# staff_only decorator
+
+def test_view_task_my_group(todo_setup, client, django_user_model):
+ # User can always view tasks that are NOT theirs IF the task is in a shared group.
+ # u1 and u2 are in different groups in the fixture -
+ # Put them in the same group.
+ g1 = Group.objects.get(name="Workgroup One")
+ u2 = django_user_model.objects.get(username="u2")
+ u2.groups.add(g1)
+
+ # Now u2 should be able to view one of u1's tasks.
+ task = Item.objects.filter(created_by__username="u1").first()
+ url = reverse('todo:task_detail', kwargs={'task_id': task.id})
+ client.login(username="u2", password="password")
+ response = client.get(url)
+ assert response.status_code == 200
+
+
+def test_view_task_not_in_my_group(todo_setup, client):
+ # User canNOT view a task that isn't theirs if the two users are not in a shared group.
+ # For this we can use the fixture data as-is.
+ task = Item.objects.filter(created_by__username="u1").first()
+ url = reverse('todo:task_detail', kwargs={'task_id': task.id})
+ client.login(username="u2", password="password")
+ response = client.get(url)
+ assert response.status_code == 403
From 97e596f46372170dd5aee923e2a27f4babbb9591 Mon Sep 17 00:00:00 2001
From: Scot Hacker
Date: Tue, 27 Mar 2018 23:17:01 -0700
Subject: [PATCH 053/210] Refactor utils to NOT take request
For testability / separation of concerns
---
todo/utils.py | 33 ++++++++++++++++++++-------------
todo/views.py | 18 +++++++++++++-----
2 files changed, 33 insertions(+), 18 deletions(-)
diff --git a/todo/utils.py b/todo/utils.py
index 8b291ad..3d50819 100644
--- a/todo/utils.py
+++ b/todo/utils.py
@@ -1,6 +1,5 @@
import datetime
-from django.contrib import messages
from django.contrib.sites.models import Site
from django.core.mail import send_mail
from django.template.loader import render_to_string
@@ -8,30 +7,38 @@ from django.template.loader import render_to_string
from todo.models import Item, Comment
-def toggle_done(request, items):
+def toggle_done(item_ids):
"""Check for items in the mark_done POST array. If present, change status to complete.
- Takes a list of task IDs. No return value.
+ Takes a list of task IDs. Returns list of status change strings.
"""
- for item in items:
- i = Item.objects.get(id=item)
+ _ret = []
+ for item_id in item_ids:
+ i = Item.objects.get(id=item_id)
old_state = "completed" if i.completed else "incomplete"
i.completed = not i.completed # Invert the done state, either way
new_state = "completed" if i.completed else "incomplete"
i.completed_date = datetime.datetime.now()
i.save()
- messages.success(request, "Item \"{i}\" changed from {o} to {n}.".format(i=i.title, o=old_state, n=new_state))
+ _ret.append("Item \"{i}\" changed from {o} to {n}.".format(i=i.title, o=old_state, n=new_state))
+
+ return _ret
-def toggle_deleted(request, deleted_items):
- # Delete selected items
- for item_id in deleted_items:
+def toggle_deleted(deleted_item_ids):
+ """Delete selected items. Returns list of status change strings.
+ """
+
+ _ret = []
+ for item_id in deleted_item_ids:
i = Item.objects.get(id=item_id)
- messages.success(request, "Item \"{i}\" deleted.".format(i=i.title))
+ _ret.append("Item \"{i}\" deleted.".format(i=i.title))
i.delete()
+ return _ret
-def send_notify_mail(request, new_task):
+
+def send_notify_mail(new_task):
# Send email to assignee if task is assigned to someone other than submittor.
# Unassigned tasks should not try to notify.
@@ -47,14 +54,14 @@ def send_notify_mail(request, new_task):
[new_task.assigned_to.email], fail_silently=False)
-def send_email_to_thread_participants(request, task):
+def send_email_to_thread_participants(task, msg_body, user):
# Notify all previous commentors on a Task about a new comment.
current_site = Site.objects.get_current()
email_subject = render_to_string("todo/email/assigned_subject.txt", {'task': task})
email_body = render_to_string(
"todo/email/newcomment_body.txt",
- {'task': task, 'body': request.POST['comment-body'], 'site': current_site, 'user': request.user}
+ {'task': task, 'body': msg_body, 'site': current_site, 'user': user}
)
# Get list of all thread participants - everyone who has commented, plus task creator.
diff --git a/todo/views.py b/todo/views.py
index bb29d1d..14973c7 100644
--- a/todo/views.py
+++ b/todo/views.py
@@ -137,8 +137,13 @@ def list_detail(request, list_id=None, list_slug=None, view_completed=False):
if request.POST:
# Process completed and deleted items on each POST
- toggle_done(request, request.POST.getlist('toggle_done_tasks'))
- toggle_deleted(request, request.POST.getlist('toggle_deleted_tasks'))
+ results_changed = toggle_done(request.POST.getlist('toggle_done_tasks'))
+ for res in results_changed:
+ messages.success(request, res)
+
+ results_changed = toggle_deleted(request, request.POST.getlist('toggle_deleted_tasks'))
+ for res in results_changed:
+ messages.success(request, res)
# ######################
# Add New Task Form
@@ -156,7 +161,7 @@ def list_detail(request, list_id=None, list_slug=None, view_completed=False):
# Send email alert only if Notify checkbox is checked AND assignee is not same as the submitter
if "notify" in request.POST and new_task.assigned_to != request.user:
- send_notify_mail(request, new_task)
+ send_notify_mail(new_task)
messages.success(request, "New task \"{t}\" has been added.".format(t=new_task.title))
return redirect(request.path)
@@ -202,7 +207,7 @@ def task_detail(request, task_id: int) -> HttpResponse:
body=request.POST['comment-body'],
)
- send_email_to_thread_participants(request, task)
+ send_email_to_thread_participants(task, request.POST['comment-body'], request.user)
messages.success(request, "Comment posted. Notification email sent to thread participants.")
# Save task edits
@@ -218,7 +223,10 @@ def task_detail(request, task_id: int) -> HttpResponse:
# Mark complete
if request.POST.get('toggle_done'):
- toggle_done(request, [task.id, ])
+ results_changed = toggle_done([task.id, ])
+ for res in results_changed:
+ messages.success(request, res)
+
return redirect('todo:task_detail', task_id=task.id,)
if task.due_date:
From a2d02b0a8c1d5afa0a873bf2e47c16c191c81f7b Mon Sep 17 00:00:00 2001
From: Scot Hacker
Date: Wed, 28 Mar 2018 00:07:29 -0700
Subject: [PATCH 054/210] Tests for non-view util functions
---
todo/tests/conftest.py | 4 +-
todo/tests/test_utils.py | 94 ++++++++++++++++++++++++++++++++++++++++
todo/utils.py | 2 +-
3 files changed, 97 insertions(+), 3 deletions(-)
create mode 100644 todo/tests/test_utils.py
diff --git a/todo/tests/conftest.py b/todo/tests/conftest.py
index aca0e88..a75c90e 100644
--- a/todo/tests/conftest.py
+++ b/todo/tests/conftest.py
@@ -10,7 +10,7 @@ def todo_setup(django_user_model):
# Two groups with different users, two sets of tasks.
g1 = Group.objects.create(name="Workgroup One")
- u1 = django_user_model.objects.create_user(username="u1", password="password")
+ u1 = django_user_model.objects.create_user(username="u1", password="password", email="u1@example.com")
u1.groups.add(g1)
tlist1 = TaskList.objects.create(group=g1, name="Zip", slug="zip")
Item.objects.create(created_by=u1, title="Task 1", task_list=tlist1, priority=1)
@@ -18,7 +18,7 @@ def todo_setup(django_user_model):
Item.objects.create(created_by=u1, title="Task 3", task_list=tlist1, priority=3)
g2 = Group.objects.create(name="Workgroup Two")
- u2 = django_user_model.objects.create_user(username="u2", password="password")
+ u2 = django_user_model.objects.create_user(username="u2", password="password", email="u2@example.com")
u2.groups.add(g2)
tlist2 = TaskList.objects.create(group=g2, name="Zap", slug="zap")
Item.objects.create(created_by=u2, title="Task 1", task_list=tlist2, priority=1)
diff --git a/todo/tests/test_utils.py b/todo/tests/test_utils.py
new file mode 100644
index 0000000..ed4c839
--- /dev/null
+++ b/todo/tests/test_utils.py
@@ -0,0 +1,94 @@
+import pytest
+
+from django.core import mail
+
+from todo.models import Item, Comment
+from todo.utils import toggle_done, toggle_deleted, send_notify_mail, send_email_to_thread_participants
+
+
+@pytest.fixture()
+# Set up an in-memory mail server to receive test emails
+def email_backend_setup(settings):
+ settings.EMAIL_BACKEND = 'django.core.mail.backends.locmem.EmailBackend'
+
+
+def test_toggle_done(todo_setup):
+ """Utility function takes an array of POSTed IDs and changes their `completed` status.
+ """
+ u1_tasks = Item.objects.filter(created_by__username="u1")
+ completed = u1_tasks.filter(completed=True)
+ incomplete = u1_tasks.filter(completed=False)
+
+ # Expected counts in fixture data
+ assert u1_tasks.count() == 3
+ assert incomplete.count() == 2
+ assert completed.count() == 1
+
+ # Mark incomplete tasks completed and check again
+ toggle_done([t.id for t in incomplete])
+ now_completed = u1_tasks.filter(created_by__username="u1", completed=True)
+ assert now_completed.count() == 3
+
+ # Mark all incomplete and check again
+ toggle_done([t.id for t in now_completed])
+ now_incomplete = u1_tasks.filter(created_by__username="u1", completed=False)
+ assert now_incomplete.count() == 3
+
+
+def test_toggle_deleted(todo_setup):
+ """Unlike toggle_done, delete means delete, so it's not really a toggle.
+ """
+ u1_tasks = Item.objects.filter(created_by__username="u1")
+ assert u1_tasks.count() == 3
+ t1 = u1_tasks.first()
+ t2 = u1_tasks.last()
+
+ toggle_deleted([t1.id, t2.id, ])
+ u1_tasks = Item.objects.filter(created_by__username="u1")
+ assert u1_tasks.count() == 1
+
+
+def test_send_notify_mail_not_me(todo_setup, django_user_model, email_backend_setup):
+ """Assign a task to someone else, mail should be sent.
+ TODO: Future tests could check for email contents.
+ """
+
+ u1 = django_user_model.objects.get(username="u1")
+ u2 = django_user_model.objects.get(username="u2")
+
+ task = Item.objects.filter(created_by=u1).first()
+ task.assigned_to = u2
+ task.save()
+ send_notify_mail(task)
+ assert len(mail.outbox) == 1
+
+
+def test_send_notify_mail_myself(todo_setup, django_user_model, email_backend_setup):
+ """Assign a task to myself, no mail should be sent.
+ """
+
+ u1 = django_user_model.objects.get(username="u1")
+ task = Item.objects.filter(created_by=u1).first()
+ task.assigned_to = u1
+ task.save()
+ send_notify_mail(task)
+ assert len(mail.outbox) == 0
+
+
+def test_send_email_to_thread_participants(todo_setup, django_user_model, email_backend_setup):
+ """For a given task authored by one user, add comments by two other users.
+ Notification email should be sent to all three users."""
+
+ u1 = django_user_model.objects.get(username="u1")
+ task = Item.objects.filter(created_by=u1).first()
+
+ u3 = django_user_model.objects.create_user(username="u3", password="zzz", email="u3@example.com")
+ u4 = django_user_model.objects.create_user(username="u4", password="zzz", email="u4@example.com")
+ Comment.objects.create(author=u3, task=task, body="Hello", )
+ Comment.objects.create(author=u4, task=task, body="Hello", )
+
+ send_email_to_thread_participants(task, "test body", u1)
+ assert len(mail.outbox) == 1 # One message to multiple recipients
+ assert 'u1@example.com' in mail.outbox[0].recipients()
+ assert 'u3@example.com' in mail.outbox[0].recipients()
+ assert 'u4@example.com' in mail.outbox[0].recipients()
diff --git a/todo/utils.py b/todo/utils.py
index 3d50819..fc7d1fa 100644
--- a/todo/utils.py
+++ b/todo/utils.py
@@ -42,7 +42,7 @@ def send_notify_mail(new_task):
# Send email to assignee if task is assigned to someone other than submittor.
# Unassigned tasks should not try to notify.
- if new_task.assigned_to:
+ if not new_task.assigned_to == new_task.created_by:
current_site = Site.objects.get_current()
email_subject = render_to_string("todo/email/assigned_subject.txt", {'task': new_task})
email_body = render_to_string(
From d3d8d5e46cccb2a42ebaeb088ba2a4c15a178498 Mon Sep 17 00:00:00 2001
From: Scot Hacker
Date: Wed, 28 Mar 2018 22:51:10 -0700
Subject: [PATCH 055/210] Rename Item model to Task
- With matching model, view, template changes
---
setup.py | 1 -
todo/admin.py | 6 +-
todo/forms.py | 10 +-
todo/migrations/0006_rename_item_model.py | 19 ++++
todo/models.py | 22 ++---
todo/templates/todo/del_list.html | 6 +-
todo/templates/todo/include/task_edit.html | 2 +-
todo/templates/todo/list_detail.html | 6 +-
todo/templates/todo/list_lists.html | 8 +-
todo/templates/todo/search_results.html | 6 +-
todo/tests/conftest.py | 14 +--
todo/tests/test_utils.py | 14 +--
todo/tests/test_views.py | 12 +--
todo/utils.py | 22 ++---
todo/views.py | 104 ++++++++++-----------
15 files changed, 133 insertions(+), 119 deletions(-)
create mode 100644 todo/migrations/0006_rename_item_model.py
diff --git a/setup.py b/setup.py
index 694a69d..9a330b9 100755
--- a/setup.py
+++ b/setup.py
@@ -21,7 +21,6 @@ setup(
'License :: OSI Approved :: BSD License',
'Operating System :: OS Independent',
'Programming Language :: Python',
- 'Programming Language :: Python :: 2',
'Programming Language :: Python :: 3',
'Topic :: Office/Business :: Groupware',
'Topic :: Software Development :: Bug Tracking',
diff --git a/todo/admin.py b/todo/admin.py
index d598552..79bb7da 100644
--- a/todo/admin.py
+++ b/todo/admin.py
@@ -1,8 +1,8 @@
from django.contrib import admin
-from todo.models import Item, TaskList, Comment
+from todo.models import Task, TaskList, Comment
-class ItemAdmin(admin.ModelAdmin):
+class TaskAdmin(admin.ModelAdmin):
list_display = ('title', 'task_list', 'completed', 'priority', 'due_date')
list_filter = ('task_list',)
ordering = ('priority',)
@@ -15,4 +15,4 @@ class CommentAdmin(admin.ModelAdmin):
admin.site.register(TaskList)
admin.site.register(Comment, CommentAdmin)
-admin.site.register(Item, ItemAdmin)
+admin.site.register(Task, TaskAdmin)
diff --git a/todo/forms.py b/todo/forms.py
index 8937765..bb988e7 100644
--- a/todo/forms.py
+++ b/todo/forms.py
@@ -1,7 +1,7 @@
from django import forms
from django.forms import ModelForm
from django.contrib.auth.models import Group
-from todo.models import Item, TaskList
+from todo.models import Task, TaskList
from django.contrib.auth import get_user_model
@@ -21,7 +21,7 @@ class AddTaskListForm(ModelForm):
exclude = []
-class AddEditItemForm(ModelForm):
+class AddEditTaskForm(ModelForm):
"""The picklist showing the users to which a new task can be assigned
must find other members of the groups the current list belongs to."""
@@ -43,11 +43,11 @@ class AddEditItemForm(ModelForm):
widget=forms.Textarea(), required=False)
class Meta:
- model = Item
+ model = Task
exclude = []
-class AddExternalItemForm(ModelForm):
+class AddExternalTaskForm(ModelForm):
"""Form to allow users who are not part of the GTD system to file a ticket."""
title = forms.CharField(
@@ -63,7 +63,7 @@ class AddExternalItemForm(ModelForm):
)
class Meta:
- model = Item
+ model = Task
exclude = (
'task_list', 'created_date', 'due_date', 'created_by', 'assigned_to', 'completed', 'completed_date', )
diff --git a/todo/migrations/0006_rename_item_model.py b/todo/migrations/0006_rename_item_model.py
new file mode 100644
index 0000000..3147460
--- /dev/null
+++ b/todo/migrations/0006_rename_item_model.py
@@ -0,0 +1,19 @@
+# Generated by Django 2.0.3 on 2018-03-28 22:40
+
+from django.conf import settings
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ('todo', '0005_auto_20180212_2325'),
+ ]
+
+ operations = [
+ migrations.RenameModel(
+ old_name='Item',
+ new_name='Task',
+ ),
+ ]
diff --git a/todo/models.py b/todo/models.py
index b767b8f..c2a324f 100644
--- a/todo/models.py
+++ b/todo/models.py
@@ -1,14 +1,12 @@
from __future__ import unicode_literals
import datetime
-from django.db import models
-from django.contrib.auth.models import Group
-from django.urls import reverse
-from django.utils.encoding import python_2_unicode_compatible
from django.conf import settings
+from django.contrib.auth.models import Group
+from django.db import models
+from django.urls import reverse
-@python_2_unicode_compatible
class TaskList(models.Model):
name = models.CharField(max_length=60)
slug = models.SlugField(default='',)
@@ -25,8 +23,7 @@ class TaskList(models.Model):
unique_together = ("group", "slug")
-@python_2_unicode_compatible
-class Item(models.Model):
+class Task(models.Model):
title = models.CharField(max_length=140)
task_list = models.ForeignKey(TaskList, on_delete=models.CASCADE, null=True)
created_date = models.DateField(auto_now=True)
@@ -41,7 +38,7 @@ class Item(models.Model):
# Has due date for an instance of this object passed?
def overdue_status(self):
- "Returns whether the item's due date has passed or not."
+ "Returns whether the Tasks's due date has passed or not."
if self.due_date and datetime.date.today() > self.due_date:
return True
@@ -51,25 +48,24 @@ class Item(models.Model):
def get_absolute_url(self):
return reverse('todo:task_detail', kwargs={'task_id': self.id, })
- # Auto-set the item creation / completed date
+ # Auto-set the Task creation / completed date
def save(self, **kwargs):
- # If Item is being marked complete, set the completed_date
+ # If Task is being marked complete, set the completed_date
if self.completed:
self.completed_date = datetime.datetime.now()
- super(Item, self).save()
+ super(Task, self).save()
class Meta:
ordering = ["priority"]
-@python_2_unicode_compatible
class Comment(models.Model):
"""
Not using Django's built-in comments because we want to be able to save
a comment and change task details at the same time. Rolling our own since it's easy.
"""
author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
- task = models.ForeignKey(Item, on_delete=models.CASCADE)
+ task = models.ForeignKey(Task, on_delete=models.CASCADE)
date = models.DateTimeField(default=datetime.datetime.now)
body = models.TextField(blank=True)
diff --git a/todo/templates/todo/del_list.html b/todo/templates/todo/del_list.html
index 9429d1e..8d9ba6d 100644
--- a/todo/templates/todo/del_list.html
+++ b/todo/templates/todo/del_list.html
@@ -8,10 +8,10 @@
{% endblock %}
\ No newline at end of file
From bb463f397419997698cdb814dd0bc841abd8d6f8 Mon Sep 17 00:00:00 2001
From: Scot Hacker
Date: Sat, 7 Apr 2018 11:34:47 -0700
Subject: [PATCH 066/210] Auto-create slug on New TaskList
---
todo/forms.py | 2 +-
todo/templates/todo/add_list.html | 6 ------
todo/views.py | 7 +++++--
3 files changed, 6 insertions(+), 9 deletions(-)
diff --git a/todo/forms.py b/todo/forms.py
index 3aea30b..0986aa7 100644
--- a/todo/forms.py
+++ b/todo/forms.py
@@ -17,7 +17,7 @@ class AddTaskListForm(ModelForm):
class Meta:
model = TaskList
- exclude = ['created_date', ]
+ exclude = ['created_date', 'slug', ]
class AddEditTaskForm(ModelForm):
diff --git a/todo/templates/todo/add_list.html b/todo/templates/todo/add_list.html
index 3eaaa46..610d918 100644
--- a/todo/templates/todo/add_list.html
+++ b/todo/templates/todo/add_list.html
@@ -13,12 +13,6 @@
The full display name for this list.
-
-
-
-
- To be used in URL for this list e.g. 'ux-tasks'. All lowercase, no spaces.
-
{{form.group}}
diff --git a/todo/views.py b/todo/views.py
index 4d5c3cb..069446b 100644
--- a/todo/views.py
+++ b/todo/views.py
@@ -12,8 +12,9 @@ from django.db.models import Q
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, render, redirect
from django.template.loader import render_to_string
-from django.views.decorators.csrf import csrf_exempt
from django.utils import timezone
+from django.utils.text import slugify
+from django.views.decorators.csrf import csrf_exempt
from todo.forms import AddTaskListForm, AddEditTaskForm, AddExternalTaskForm, SearchForm
from todo.models import Task, TaskList, Comment
@@ -280,7 +281,9 @@ def add_list(request) -> HttpResponse:
form = AddTaskListForm(request.user, request.POST)
if form.is_valid():
try:
- form.save()
+ newlist = form.save(commit=False)
+ newlist.slug = slugify(newlist.name)
+ newlist.save()
messages.success(request, "A new list has been added.")
return redirect('todo:lists')
From f0d2725ceb37564c91a67dd8b919720c329c6a6a Mon Sep 17 00:00:00 2001
From: Scot Hacker
Date: Sat, 7 Apr 2018 11:45:06 -0700
Subject: [PATCH 067/210] gitignire .pytest_cache
---
.gitignore | 1 +
todo/templates/todo/base.html | 1 -
2 files changed, 1 insertion(+), 1 deletion(-)
diff --git a/.gitignore b/.gitignore
index e5e5ade..883ec37 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,3 +10,4 @@ settings.json
# Django and Python
*.py[cod]
+.pytest_cache/*
diff --git a/todo/templates/todo/base.html b/todo/templates/todo/base.html
index cfe6514..c2edad7 100644
--- a/todo/templates/todo/base.html
+++ b/todo/templates/todo/base.html
@@ -5,4 +5,3 @@
{% endblock extrahead %}
-
From b570d30f83727c34b89dfe901a8e38cfe33cca0c Mon Sep 17 00:00:00 2001
From: Scot Hacker
Date: Sat, 7 Apr 2018 11:53:30 -0700
Subject: [PATCH 068/210] Bump version to 2.0
---
todo/__init__.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/todo/__init__.py b/todo/__init__.py
index dd209ab..7949668 100644
--- a/todo/__init__.py
+++ b/todo/__init__.py
@@ -1,7 +1,7 @@
"""
A multi-user, multi-group task management and assignment system for Django.
"""
-__version__ = '1.6.2'
+__version__ = '2.0'
__author__ = 'Scot Hacker'
__email__ = 'shacker@birdhouse.org'
From 416419301c00f9421634e09eddb65b17b5964ad3 Mon Sep 17 00:00:00 2001
From: Scot Hacker
Date: Sat, 7 Apr 2018 13:23:36 -0700
Subject: [PATCH 069/210] README update for version 2
---
README.md | 212 ++++++++++++++++++++++++++++++++++++++++++++++++++++--
1 file changed, 205 insertions(+), 7 deletions(-)
diff --git a/README.md b/README.md
index 7bbd9d4..ae0003c 100644
--- a/README.md
+++ b/README.md
@@ -1,15 +1,213 @@
# django-todo
django-todo is a pluggable, multi-user, multi-group task management and
-assignment application for Django, designed to be dropped into an existing site as a reusable app.
+assignment application for Django, designed to be dropped into an existing site as a reusable app. django-todo can be used as a personal to-do tracker, or a group task management system, or a ticketing system for organizations (or all of these at once!)
-django-todo can serve as anything from a personal to-do tracker to a ticketing system for organizations.
+## Features
-## Documentation
+* Drag and drop task prioritization
+* Email task notification
+* Search
+* Comments on tasks
+* Public-facing submission form for tickets
+* Mobile-friendly (work in progress)
+* Separate view for My Tasks (across lists)
-For documentation, see the django-todo wiki pages:
-- [Overview and screenshots](http://github.com/shacker/django-todo/wiki/Overview-and-screenshots)
-- [Requirements and installation](http://github.com/shacker/django-todo/wiki/Requirements-and-Installation)
-- [Version history](http://github.com/shacker/django-todo/wiki/Version-history)
+## Requirements
+
+* Django 2.0+
+* Python 3.3+
+* jQuery (full version, not "slim", for drag/drop prioritization)
+* Bootstrap (to work with provided templates, though you can override them)
+
+## Overview
+
+**The best way to learn how django-todo works is to visit the live demo site at [django-todo.org](http://django-todo.org)!**
+
+The assumption is that your organization/publication/company has multiple groups of employees, each with multiple users (where actual users and groups map to Django Users and Groups). Users may belong to multiple groups, and each group can have multiple todo lists.
+
+You must have at least one Group set up in Django admin, and that group must have at least one User as a member. This is true even if you're the sole user of django-todo.
+
+Users can view and modify all to-do lists belonging to their group(s). Only users with `is_staff()` can add or delete lists.
+
+Identical list names can exist in different groups, but not in the same group.
+
+Emails are generated to the assigned-to person when new tasks are created.
+
+Comment threads can be added to tasks. Each participant in a thread receives email when new comments are added.
+
+django-todo is auth-only. You must set up a login system and at least one group before deploying.
+
+All tasks are "created by" the current user and can optionally be "assigned to" a specific user. Unassigned tickets appear as belonging to "anyone" in the UI.
+
+django-todo v2 makes use of features only available in Django 2.0. It will not work in previous versions. v2 is only tested against Python 3.x -- no guarantees if running it against older versions.
+
+# Installation
+
+django-todo is a Django app, not a project site. It needs a site to live in. You can either install it into an existing Django project site, or clone the django-todo [demo site (GTD)](https://github.com/shacker/gtd).
+
+If using your own site, be sure you have jQuery and Bootstrap wired up and working.
+
+django-todo pages that require it will insert additional CSS/JavaScript into page heads,
+so your project's base templates must include:
+
+```
+{% block extrahead %}{% endblock extrahead %}
+{% block extra_js %}{% endblock extra_js %}
+```
+
+django-todo comes with its own `todo/base.html`, which extends your master `base.html`. All content lives inside of:
+
+`{% block content %}{% endblock %}`
+
+If you use some other name for your main content area, you'll need to override and alter the provided templates.
+
+All views are login-required. Therefore, you must have a working user authentication system.
+
+For email notifications to work, make sure your site/project is [set up to send email](https://docs.djangoproject.com/en/2.0/topics/email/).
+
+Make sure you've installed the Django "sites" framework and have specified the default site in settings, e.g. `SITE_ID = 1`
+
+Put django-todo/todo somewhere on your Python path, or install via pip:
+
+ pip install django-todo
+
+
+Add to your settings:
+
+ INSTALLED_APPS = (
+ ...
+ 'todo',
+ )
+
+Create database tables:
+
+ python manage.py migrate todo
+
+Add to your URL conf:
+
+ path('todo/', include('todo.urls', namespace="todo")),
+
+Add links to your site's navigation system:
+
+ Todo Lists
+ My Tasks
+
+django-todo makes use of the Django `messages` system. Make sure you have something like [this](https://docs.djangoproject.com/en/2.0/ref/contrib/messages/#displaying-messages) in your `base.html`.
+
+Log in and access `/todo`!
+
+The provided templates are fairly bare-bones, and are meant as starting points only. Unlike previous versions of django-todo, they now ship as Bootstrap examples, but feel free to override them - there is no hard dependency on Bootstrap. To override a template, create a `todo` folder in your project's `templates` dir, then copy the template you want to override from django-todo source and into that dir.
+
+If you wish to use the public ticket-filing system, first create the list into which those tickets should be filed, then add its slug to `TODO_DEFAULT_LIST_SLUG` in settings (more on settings below).
+
+## Settings
+
+Optional configuration options:
+
+```
+# Restrict access to todo lists/views to `is_staff()` users.
+# False here falls back to `is_authenticated()` users.
+TODO_STAFF_ONLY = True
+
+# If you use the "public" ticket filing option, to whom should these tickets be assigned?
+# Must be a valid username in your system. If unset, unassigned tickets go to "Anyone."
+TODO_DEFAULT_ASSIGNEE = 'johndoe'
+
+# If you use the "public" ticket filing option, to which list should these tickets be saved?
+# Defaults to first list found, which is probably not what you want!
+TODO_DEFAULT_LIST_SLUG = 'tickets'
+
+# If you use the "public" ticket filing option, to which *named URL* should the user be
+# redirected after submitting? (since they can't see the rest of the ticket system).
+# Defaults to "/"
+TODO_PUBLIC_SUBMIT_REDIRECT = 'dashboard'
+
+```
+
+The current django-todo version number is available from the [todo package](https://github.com/shacker/django-todo/blob/master/todo/__init__.py):
+
+ python -c "import todo; print(todo.__version__)"
+
+
+## Upgrade Notes
+
+django-todo 2.0 was rebuilt almost from the ground up, and included some radical changes, including model name changes. As a result, it is *not compatible* with data from django-todo 1.x. If you would like to upgrade an existing installation, try this:
+
+* Use `./manage.py dumpdata todo --indent 4 > todo.json` to export your old todo data
+* Edit the dump file, replacing the old model names `Item` and `List` with the new model names (`Task` and `TaskList`)
+* Delete your existing todo data
+* Uninstall the old todo app and reinstall
+* Migrate, then use `./manage.py loaddata todo.json` to import the edited data
+
+### Why not provide migrations?
+
+That was the plan, but unfortunately, `makemigrations` created new tables and dropped the old ones, making this a destructive update. Renaming models is unfortunately not something `makemigrations` can do, and I really didn't want to keep the badly named original models. Sorry!
+
+### Datepicker
+
+django-todo no longer references a jQuery datepicker, but defaults to native html5 browser datepicker (not supported by Safari, unforunately). Feel free to implement one of your choosing.
+
+### URLs
+
+Some views and URLs were renamed for logical consistency. If this affects you, see source code and the demo GTD site for reference to the new URL names.
+
+
+## Running Tests
+
+django-todo uses pytest exclusively for testing. The best way to run the suite is to clone django-todo into its own directory, install pytest, then:
+
+ pip install pytest pytest-django
+ pip install --editable .
+ pytest -x -v
+
+The previous `tox` system was removed with the v2 release, since we no longer aim to support older Python or Django versions.
+
+# Version History
+
+**2.0** April 2018: Major project refactor, with almost completely rewritten views, templates, and todo's first real test suite.
+
+**1.6.2** Added support for unicode characters in list name/slugs.
+
+**1.6.1** Minor bug fixes.
+
+**1.6** Allow unassigned ("Anyone") tasks. Clean-up / modernize templates and views. Testing infrastructure in place.
+
+**1.5** flake8 support, Item note no longer a required field, fix warnings for Django 1.8, Python 2/3-compatible unicode strings, simple search for tasks, get_absolute_url() for items.
+
+**1.4** - Removed styling from default templates. Added excludes fields from Form definitions to prevent warnings. Removed deprecated 'cycle' tags from templates. Added settings for various elements for public ticket submissions.
+
+**1.3** - Removed stray direct_to_template reference. Quoted all named URL references for Django 1.5 compatibility.
+
+**1.2** - Added CSRF protection to all sample templates. Added integrated search function. Now showing the ratio of completed/total items for each
+list. Better separation of media and templates. Cleaned up Item editing form (removed extraneous fields). Re-assigning tasks now properly limits
+the list of assignees. Moved project to github.
+
+**1.1** - Completion date was set properly when checking items off a list, but not when saving from an Item detail page. Added a save method on Item to
+fix. Fixed documentation bug re: context_processors. Newly added comments are now emailed to everyone who has participated in a thread on a task.
+
+**1.0.1** - When viewing a single task that you want to close, it's useful to be able to comment on and close a task at the same time. We were using
+django-comments so these were different models in different views. Solution was to stop using django-comments and roll our own, then rewire the
+view. Apologies if you were using a previous version - you may need to port over your comments to the new system.
+
+**1.0.0** - Major upgrade to release version. Drag and drop task prioritization. E-mail notifications (now works more like a ticket system). More
+attractive date picker. Bug fixes.
+
+**0.9.5** - Fixed jquery bug when editing existing events - datepicker now shows correct date. Removed that damned Django pony from base template.
+
+**0.9.4** - Replaced str with unicode in models. Fixed links back to lists in "My Tasks" view.
+
+**0.9.3** - Missing link to the individual task editing view
+
+**0.9.2** - Now fails gracefully when trying to add a 2nd list with the same name to the same group. - Due dates for tasks are now truly optional. -
+Corrected datetime editing conflict when editing tasks - Max length of a task name has been raised from 60 to 140 chars. If upgrading, please
+modify your database accordingly (field todo_item.name = maxlength 140). - Security: Users supplied with direct task URLs can no longer view/edit
+tasks outside their group scope Same for list views - authorized views only. - Correct item and group counts on homepage (note - admin users see
+ALL groups, not just the groups they "belong" to)
+
+**0.9.1** - Removed context_processors.py - leftover turdlet
+
+**0.9** - First release
+
From 50d182103c46a195afc8ad64c5948d8419e17ece Mon Sep 17 00:00:00 2001
From: Scot Hacker
Date: Sat, 7 Apr 2018 13:42:34 -0700
Subject: [PATCH 070/210] Rename readme to index for readthedocs to find
---
README.md => index.md | 0
1 file changed, 0 insertions(+), 0 deletions(-)
rename README.md => index.md (100%)
diff --git a/README.md b/index.md
similarity index 100%
rename from README.md
rename to index.md
From d169f131a279549739b9eb8d952a697b19d23fb9 Mon Sep 17 00:00:00 2001
From: Scot Hacker
Date: Sat, 7 Apr 2018 13:50:53 -0700
Subject: [PATCH 071/210] Documentation tweak
---
index.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/index.md b/index.md
index ae0003c..269ff69 100644
--- a/index.md
+++ b/index.md
@@ -3,6 +3,8 @@
django-todo is a pluggable, multi-user, multi-group task management and
assignment application for Django, designed to be dropped into an existing site as a reusable app. django-todo can be used as a personal to-do tracker, or a group task management system, or a ticketing system for organizations (or all of these at once!)
+**The best way to learn how django-todo works is to visit the live demo site at [django-todo.org](http://django-todo.org)!**
+
## Features
* Drag and drop task prioritization
@@ -23,8 +25,6 @@ assignment application for Django, designed to be dropped into an existing site
## Overview
-**The best way to learn how django-todo works is to visit the live demo site at [django-todo.org](http://django-todo.org)!**
-
The assumption is that your organization/publication/company has multiple groups of employees, each with multiple users (where actual users and groups map to Django Users and Groups). Users may belong to multiple groups, and each group can have multiple todo lists.
You must have at least one Group set up in Django admin, and that group must have at least one User as a member. This is true even if you're the sole user of django-todo.
From 4fe3829b98cfe5b4b81bf9c753ea2eb779915596 Mon Sep 17 00:00:00 2001
From: Scot Hacker
Date: Sat, 7 Apr 2018 23:31:24 -0700
Subject: [PATCH 072/210] Fix silent crasher when reordering table rows
---
todo/templates/todo/list_detail.html | 6 +++---
todo/views.py | 8 ++++----
2 files changed, 7 insertions(+), 7 deletions(-)
diff --git a/todo/templates/todo/list_detail.html b/todo/templates/todo/list_detail.html
index 68048ce..4a8998e 100644
--- a/todo/templates/todo/list_detail.html
+++ b/todo/templates/todo/list_detail.html
@@ -25,7 +25,7 @@
{% csrf_token %}
-
+
Done
Task
Created
@@ -93,8 +93,8 @@
bar"
+ data = {
+ "task_list": task_list.id,
+ "created_by": user.id,
+ "priority": 10,
+ "title": title,
+ "note": note,
+ 'add_edit_task': 'Submit'
+ }
+
+ client.login(username='u2', password="password")
+ url = reverse('todo:list_detail', kwargs={"list_id": task_list.id, "list_slug": task_list.slug})
+
+ response = client.post(url, data)
+ assert response.status_code == 302
+
+ # Retrieve new task and compare notes field
+ task = Task.objects.get(title=title)
+ assert task.note != note # Should have been modified by bleach since note included javascript!
+ assert task.note == bleach.clean(note, strip=True)
+
+
+@pytest.mark.django_db
+def test_no_javascript_in_comments(todo_setup, client):
+ user = get_user_model().objects.get(username="u2")
+ client.login(username='u2', password="password")
+
+ task = Task.objects.first()
+ task.created_by = user
+ task.save()
+
+ user.groups.add(task.task_list.group)
+
+ comment = "foo bar"
+ data = {
+ "comment-body": comment,
+ "add_comment": 'Submit'
+ }
+ url = reverse('todo:task_detail', kwargs={"task_id": task.id})
+
+ response = client.post(url, data)
+ assert response.status_code == 200
+
+ task.refresh_from_db()
+ newcomment = task.comment_set.last()
+ assert newcomment != comment # Should have been modified by bleach
+ assert newcomment.body == bleach.clean(comment, strip=True)
+
+
# ### PERMISSIONS ###
"""
@@ -139,9 +196,9 @@ def test_view_task_mine(todo_setup, client):
def test_view_task_my_group(todo_setup, client, django_user_model):
- # User can always view tasks that are NOT theirs IF the task is in a shared group.
- # u1 and u2 are in different groups in the fixture -
- # Put them in the same group.
+ """User can always view tasks that are NOT theirs IF the task is in a shared group.
+ u1 and u2 are in different groups in the fixture -
+ Put them in the same group."""
g1 = Group.objects.get(name="Workgroup One")
u2 = django_user_model.objects.get(username="u2")
u2.groups.add(g1)
diff --git a/todo/views.py b/todo/views.py
index d1c20d9..ba01504 100644
--- a/todo/views.py
+++ b/todo/views.py
@@ -1,4 +1,5 @@
import datetime
+import bleach
from django.conf import settings
from django.contrib import messages
@@ -150,6 +151,7 @@ def list_detail(request, list_id=None, list_slug=None, view_completed=False):
if form.is_valid():
new_task = form.save(commit=False)
new_task.created_date = timezone.now()
+ new_task.note = bleach.clean(form.cleaned_data['note'], strip=True)
form.save()
# Send email alert only if Notify checkbox is checked AND assignee is not same as the submitter
@@ -197,7 +199,7 @@ def task_detail(request, task_id: int) -> HttpResponse:
Comment.objects.create(
author=request.user,
task=task,
- body=request.POST['comment-body'],
+ body=bleach.clean(request.POST['comment-body'], strip=True),
)
send_email_to_thread_participants(
@@ -210,7 +212,9 @@ def task_detail(request, task_id: int) -> HttpResponse:
form = AddEditTaskForm(request.user, request.POST, instance=task, initial={'task_list': task.task_list})
if form.is_valid():
- form.save()
+ item = form.save(commit=False)
+ item.note = bleach.clean(form.cleaned_data['note'], strip=True)
+ item.save()
messages.success(request, "The task has been edited.")
return redirect('todo:list_detail', list_id=task.task_list.id, list_slug=task.task_list.slug)
else:
From 21ec87cee46a4d16e7e4ccca481fccd618cd87f3 Mon Sep 17 00:00:00 2001
From: Scot Hacker
Date: Fri, 21 Dec 2018 00:38:44 -0800
Subject: [PATCH 085/210] Black formatting
---
setup.py | 24 ++---
test_settings.py | 73 +++++++-------
todo/admin.py | 10 +-
todo/forms.py | 65 ++++++------
todo/models.py | 18 +++-
todo/tests/conftest.py | 9 +-
todo/tests/test_utils.py | 20 ++--
todo/tests/test_views.py | 53 +++++-----
todo/utils.py | 20 ++--
todo/views.py | 211 ++++++++++++++++++++++-----------------
10 files changed, 276 insertions(+), 227 deletions(-)
diff --git a/setup.py b/setup.py
index ad3dca3..7f0ed19 100755
--- a/setup.py
+++ b/setup.py
@@ -5,7 +5,7 @@ from setuptools import setup, find_packages
import todo as package
setup(
- name='django-todo',
+ name="django-todo",
version=package.__version__,
description=package.__doc__.strip(),
author=package.__author__,
@@ -14,18 +14,18 @@ setup(
license=package.__license__,
packages=find_packages(),
classifiers=[
- 'Development Status :: 5 - Production/Stable',
- 'Environment :: Web Environment',
- 'Framework :: Django',
- 'Intended Audience :: Developers',
- 'License :: OSI Approved :: BSD License',
- 'Operating System :: OS Independent',
- 'Programming Language :: Python',
- 'Programming Language :: Python :: 3',
- 'Topic :: Office/Business :: Groupware',
- 'Topic :: Software Development :: Bug Tracking',
+ "Development Status :: 5 - Production/Stable",
+ "Environment :: Web Environment",
+ "Framework :: Django",
+ "Intended Audience :: Developers",
+ "License :: OSI Approved :: BSD License",
+ "Operating System :: OS Independent",
+ "Programming Language :: Python",
+ "Programming Language :: Python :: 3",
+ "Topic :: Office/Business :: Groupware",
+ "Topic :: Software Development :: Bug Tracking",
],
include_package_data=True,
zip_safe=False,
- install_requires=['unidecode', ],
+ install_requires=["unidecode"],
)
diff --git a/test_settings.py b/test_settings.py
index 26bc9c6..157b21b 100644
--- a/test_settings.py
+++ b/test_settings.py
@@ -1,64 +1,63 @@
import os
-DEBUG = True,
+DEBUG = (True,)
BASE_DIR = os.path.dirname(os.path.dirname(__file__))
-print("bd ", BASE_DIR)
DATABASES = {
- 'default': {
- 'ENGINE': 'django.db.backends.sqlite3',
+ "default": {
+ "ENGINE": "django.db.backends.sqlite3"
}
}
-EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
+EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
# Document
TODO_STAFF_ONLY = False
-TODO_DEFAULT_LIST_SLUG = 'tickets'
+TODO_DEFAULT_LIST_SLUG = "tickets"
TODO_DEFAULT_ASSIGNEE = None
-TODO_PUBLIC_SUBMIT_REDIRECT = '/'
+TODO_PUBLIC_SUBMIT_REDIRECT = "/"
SECRET_KEY = "LKFSD8sdl.,8&sdf--"
SITE_ID = 1
INSTALLED_APPS = (
- 'django.contrib.admin',
- 'django.contrib.auth',
- 'django.contrib.contenttypes',
- 'django.contrib.messages',
- 'django.contrib.sessions',
- 'django.contrib.sites',
- 'django.contrib.staticfiles',
- 'todo',
+ "django.contrib.admin",
+ "django.contrib.auth",
+ "django.contrib.contenttypes",
+ "django.contrib.messages",
+ "django.contrib.sessions",
+ "django.contrib.sites",
+ "django.contrib.staticfiles",
+ "todo",
)
-ROOT_URLCONF = 'base_urls'
+ROOT_URLCONF = "base_urls"
MIDDLEWARE = [
- 'django.middleware.security.SecurityMiddleware',
- 'django.contrib.sessions.middleware.SessionMiddleware',
- 'django.middleware.common.CommonMiddleware',
- 'django.middleware.csrf.CsrfViewMiddleware',
- 'django.contrib.auth.middleware.AuthenticationMiddleware',
- 'django.contrib.messages.middleware.MessageMiddleware',
- 'django.middleware.clickjacking.XFrameOptionsMiddleware',
+ "django.middleware.security.SecurityMiddleware",
+ "django.contrib.sessions.middleware.SessionMiddleware",
+ "django.middleware.common.CommonMiddleware",
+ "django.middleware.csrf.CsrfViewMiddleware",
+ "django.contrib.auth.middleware.AuthenticationMiddleware",
+ "django.contrib.messages.middleware.MessageMiddleware",
+ "django.middleware.clickjacking.XFrameOptionsMiddleware",
]
TEMPLATES = [
{
- 'BACKEND': 'django.template.backends.django.DjangoTemplates',
- 'DIRS': [os.path.join(BASE_DIR, 'todo', 'templates'), ],
- 'APP_DIRS': True,
- 'OPTIONS': {
- 'context_processors': [
- 'django.template.context_processors.debug',
- 'django.template.context_processors.request',
- 'django.contrib.auth.context_processors.auth',
- 'django.template.context_processors.media',
- 'django.template.context_processors.static',
- 'django.contrib.messages.context_processors.messages',
+ "BACKEND": "django.template.backends.django.DjangoTemplates",
+ "DIRS": [os.path.join(BASE_DIR, "todo", "templates")],
+ "APP_DIRS": True,
+ "OPTIONS": {
+ "context_processors": [
+ "django.template.context_processors.debug",
+ "django.template.context_processors.request",
+ "django.contrib.auth.context_processors.auth",
+ "django.template.context_processors.media",
+ "django.template.context_processors.static",
+ "django.contrib.messages.context_processors.messages",
# Your stuff: custom template context processors go here
- ],
+ ]
},
- },
-]
\ No newline at end of file
+ }
+]
diff --git a/todo/admin.py b/todo/admin.py
index 79bb7da..fa5d4ef 100644
--- a/todo/admin.py
+++ b/todo/admin.py
@@ -3,14 +3,14 @@ from todo.models import Task, TaskList, Comment
class TaskAdmin(admin.ModelAdmin):
- list_display = ('title', 'task_list', 'completed', 'priority', 'due_date')
- list_filter = ('task_list',)
- ordering = ('priority',)
- search_fields = ('name',)
+ list_display = ("title", "task_list", "completed", "priority", "due_date")
+ list_filter = ("task_list",)
+ ordering = ("priority",)
+ search_fields = ("name",)
class CommentAdmin(admin.ModelAdmin):
- list_display = ('author', 'date', 'snippet')
+ list_display = ("author", "date", "snippet")
admin.site.register(TaskList)
diff --git a/todo/forms.py b/todo/forms.py
index 0986aa7..4793c46 100644
--- a/todo/forms.py
+++ b/todo/forms.py
@@ -11,13 +11,16 @@ class AddTaskListForm(ModelForm):
def __init__(self, user, *args, **kwargs):
super(AddTaskListForm, self).__init__(*args, **kwargs)
- self.fields['group'].queryset = Group.objects.filter(user=user)
- self.fields['group'].widget.attrs = {
- 'id': 'id_group', 'class': "custom-select mb-3", 'name': 'group'}
+ self.fields["group"].queryset = Group.objects.filter(user=user)
+ self.fields["group"].widget.attrs = {
+ "id": "id_group",
+ "class": "custom-select mb-3",
+ "name": "group",
+ }
class Meta:
model = TaskList
- exclude = ['created_date', 'slug', ]
+ exclude = ["created_date", "slug"]
class AddEditTaskForm(ModelForm):
@@ -26,22 +29,25 @@ class AddEditTaskForm(ModelForm):
def __init__(self, user, *args, **kwargs):
super().__init__(*args, **kwargs)
- task_list = kwargs.get('initial').get('task_list')
+ task_list = kwargs.get("initial").get("task_list")
members = task_list.group.user_set.all()
- self.fields['assigned_to'].queryset = members
- self.fields['assigned_to'].label_from_instance = lambda obj: "%s (%s)" % (obj.get_full_name(), obj.username)
- self.fields['assigned_to'].widget.attrs = {
- 'id': 'id_assigned_to', 'class': "custom-select mb-3", 'name': 'assigned_to'}
- self.fields['task_list'].value = kwargs['initial']['task_list'].id
+ self.fields["assigned_to"].queryset = members
+ self.fields["assigned_to"].label_from_instance = lambda obj: "%s (%s)" % (
+ obj.get_full_name(),
+ obj.username,
+ )
+ self.fields["assigned_to"].widget.attrs = {
+ "id": "id_assigned_to",
+ "class": "custom-select mb-3",
+ "name": "assigned_to",
+ }
+ self.fields["task_list"].value = kwargs["initial"]["task_list"].id
- due_date = forms.DateField(
- widget=forms.DateInput(attrs={'type': 'date'}), required=False)
+ due_date = forms.DateField(widget=forms.DateInput(attrs={"type": "date"}), required=False)
- title = forms.CharField(
- widget=forms.widgets.TextInput())
+ title = forms.CharField(widget=forms.widgets.TextInput())
- note = forms.CharField(
- widget=forms.Textarea(), required=False)
+ note = forms.CharField(widget=forms.Textarea(), required=False)
class Meta:
model = Task
@@ -51,27 +57,24 @@ class AddEditTaskForm(ModelForm):
class AddExternalTaskForm(ModelForm):
"""Form to allow users who are not part of the GTD system to file a ticket."""
- title = forms.CharField(
- widget=forms.widgets.TextInput(attrs={'size': 35}),
- label="Summary"
- )
- note = forms.CharField(
- widget=forms.widgets.Textarea(),
- label='Problem Description',
- )
- priority = forms.IntegerField(
- widget=forms.HiddenInput(),
- )
+ title = forms.CharField(widget=forms.widgets.TextInput(attrs={"size": 35}), label="Summary")
+ note = forms.CharField(widget=forms.widgets.Textarea(), label="Problem Description")
+ priority = forms.IntegerField(widget=forms.HiddenInput())
class Meta:
model = Task
exclude = (
- 'task_list', 'created_date', 'due_date', 'created_by', 'assigned_to', 'completed', 'completed_date', )
+ "task_list",
+ "created_date",
+ "due_date",
+ "created_by",
+ "assigned_to",
+ "completed",
+ "completed_date",
+ )
class SearchForm(forms.Form):
"""Search."""
- q = forms.CharField(
- widget=forms.widgets.TextInput(attrs={'size': 35})
- )
+ q = forms.CharField(widget=forms.widgets.TextInput(attrs={"size": 35}))
diff --git a/todo/models.py b/todo/models.py
index ee5955a..89a1837 100644
--- a/todo/models.py
+++ b/todo/models.py
@@ -10,7 +10,7 @@ from django.utils import timezone
class TaskList(models.Model):
name = models.CharField(max_length=60)
- slug = models.SlugField(default='',)
+ slug = models.SlugField(default="")
group = models.ForeignKey(Group, on_delete=models.CASCADE)
def __str__(self):
@@ -28,12 +28,19 @@ class Task(models.Model):
title = models.CharField(max_length=140)
task_list = models.ForeignKey(TaskList, on_delete=models.CASCADE, null=True)
created_date = models.DateField(default=timezone.now, blank=True, null=True)
- due_date = models.DateField(blank=True, null=True, )
+ due_date = models.DateField(blank=True, null=True)
completed = models.BooleanField(default=False)
completed_date = models.DateField(blank=True, null=True)
- created_by = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='todo_created_by', on_delete=models.CASCADE)
+ created_by = models.ForeignKey(
+ settings.AUTH_USER_MODEL, related_name="todo_created_by", on_delete=models.CASCADE
+ )
assigned_to = models.ForeignKey(
- settings.AUTH_USER_MODEL, blank=True, null=True, related_name='todo_assigned_to', on_delete=models.CASCADE)
+ settings.AUTH_USER_MODEL,
+ blank=True,
+ null=True,
+ related_name="todo_assigned_to",
+ on_delete=models.CASCADE,
+ )
note = models.TextField(blank=True, null=True)
priority = models.PositiveIntegerField()
@@ -47,7 +54,7 @@ class Task(models.Model):
return self.title
def get_absolute_url(self):
- return reverse('todo:task_detail', kwargs={'task_id': self.id, })
+ return reverse("todo:task_detail", kwargs={"task_id": self.id})
# Auto-set the Task creation / completed date
def save(self, **kwargs):
@@ -65,6 +72,7 @@ class Comment(models.Model):
Not using Django's built-in comments because we want to be able to save
a comment and change task details at the same time. Rolling our own since it's easy.
"""
+
author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
task = models.ForeignKey(Task, on_delete=models.CASCADE)
date = models.DateTimeField(default=datetime.datetime.now)
diff --git a/todo/tests/conftest.py b/todo/tests/conftest.py
index a2514f4..dc6c6bc 100644
--- a/todo/tests/conftest.py
+++ b/todo/tests/conftest.py
@@ -5,13 +5,14 @@ from django.contrib.auth.models import Group
from todo.models import Task, TaskList
-
@pytest.fixture
def todo_setup(django_user_model):
# Two groups with different users, two sets of tasks.
g1 = Group.objects.create(name="Workgroup One")
- u1 = django_user_model.objects.create_user(username="u1", password="password", email="u1@example.com")
+ u1 = django_user_model.objects.create_user(
+ username="u1", password="password", email="u1@example.com"
+ )
u1.groups.add(g1)
tlist1 = TaskList.objects.create(group=g1, name="Zip", slug="zip")
Task.objects.create(created_by=u1, title="Task 1", task_list=tlist1, priority=1)
@@ -19,7 +20,9 @@ def todo_setup(django_user_model):
Task.objects.create(created_by=u1, title="Task 3", task_list=tlist1, priority=3)
g2 = Group.objects.create(name="Workgroup Two")
- u2 = django_user_model.objects.create_user(username="u2", password="password", email="u2@example.com")
+ u2 = django_user_model.objects.create_user(
+ username="u2", password="password", email="u2@example.com"
+ )
u2.groups.add(g2)
tlist2 = TaskList.objects.create(group=g2, name="Zap", slug="zap")
Task.objects.create(created_by=u2, title="Task 1", task_list=tlist2, priority=1)
diff --git a/todo/tests/test_utils.py b/todo/tests/test_utils.py
index aa505eb..0ac4c8a 100644
--- a/todo/tests/test_utils.py
+++ b/todo/tests/test_utils.py
@@ -9,7 +9,7 @@ from todo.utils import send_notify_mail, send_email_to_thread_participants
@pytest.fixture()
# Set up an in-memory mail server to receive test emails
def email_backend_setup(settings):
- settings.EMAIL_BACKEND = 'django.core.mail.backends.locmem.EmailBackend'
+ settings.EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"
def test_send_notify_mail_not_me(todo_setup, django_user_model, email_backend_setup):
@@ -46,13 +46,17 @@ def test_send_email_to_thread_participants(todo_setup, django_user_model, email_
u1 = django_user_model.objects.get(username="u1")
task = Task.objects.filter(created_by=u1).first()
- u3 = django_user_model.objects.create_user(username="u3", password="zzz", email="u3@example.com")
- u4 = django_user_model.objects.create_user(username="u4", password="zzz", email="u4@example.com")
- Comment.objects.create(author=u3, task=task, body="Hello", )
- Comment.objects.create(author=u4, task=task, body="Hello", )
+ u3 = django_user_model.objects.create_user(
+ username="u3", password="zzz", email="u3@example.com"
+ )
+ u4 = django_user_model.objects.create_user(
+ username="u4", password="zzz", email="u4@example.com"
+ )
+ Comment.objects.create(author=u3, task=task, body="Hello")
+ Comment.objects.create(author=u4, task=task, body="Hello")
send_email_to_thread_participants(task, "test body", u1)
assert len(mail.outbox) == 1 # One message to multiple recipients
- assert 'u1@example.com' in mail.outbox[0].recipients()
- assert 'u3@example.com' in mail.outbox[0].recipients()
- assert 'u4@example.com' in mail.outbox[0].recipients()
+ assert "u1@example.com" in mail.outbox[0].recipients()
+ assert "u3@example.com" in mail.outbox[0].recipients()
+ assert "u4@example.com" in mail.outbox[0].recipients()
diff --git a/todo/tests/test_views.py b/todo/tests/test_views.py
index 4f44e8a..dbfa1d0 100644
--- a/todo/tests/test_views.py
+++ b/todo/tests/test_views.py
@@ -16,19 +16,20 @@ After that, view contents and behaviors.
# ### SMOKETESTS ###
+
@pytest.mark.django_db
def test_todo_setup(todo_setup):
assert Task.objects.all().count() == 6
def test_view_list_lists(todo_setup, admin_client):
- url = reverse('todo:lists')
+ url = reverse("todo:lists")
response = admin_client.get(url)
assert response.status_code == 200
def test_view_reorder(todo_setup, admin_client):
- url = reverse('todo:reorder_tasks')
+ url = reverse("todo:reorder_tasks")
response = admin_client.get(url)
assert response.status_code == 201 # Special case return value expected
@@ -37,53 +38,55 @@ def test_view_external_add(todo_setup, admin_client, settings):
default_list = TaskList.objects.first()
settings.TODO_DEFAULT_LIST_SLUG = default_list.slug
assert settings.TODO_DEFAULT_LIST_SLUG == default_list.slug
- url = reverse('todo:external_add')
+ url = reverse("todo:external_add")
response = admin_client.get(url)
assert response.status_code == 200
def test_view_mine(todo_setup, admin_client):
- url = reverse('todo:mine')
+ url = reverse("todo:mine")
response = admin_client.get(url)
assert response.status_code == 200
def test_view_list_completed(todo_setup, admin_client):
tlist = TaskList.objects.get(slug="zip")
- url = reverse('todo:list_detail_completed', kwargs={'list_id': tlist.id, 'list_slug': tlist.slug})
+ url = reverse(
+ "todo:list_detail_completed", kwargs={"list_id": tlist.id, "list_slug": tlist.slug}
+ )
response = admin_client.get(url)
assert response.status_code == 200
def test_view_list(todo_setup, admin_client):
tlist = TaskList.objects.get(slug="zip")
- url = reverse('todo:list_detail', kwargs={'list_id': tlist.id, 'list_slug': tlist.slug})
+ url = reverse("todo:list_detail", kwargs={"list_id": tlist.id, "list_slug": tlist.slug})
response = admin_client.get(url)
assert response.status_code == 200
def test_del_list(todo_setup, admin_client):
tlist = TaskList.objects.get(slug="zip")
- url = reverse('todo:del_list', kwargs={'list_id': tlist.id, 'list_slug': tlist.slug})
+ url = reverse("todo:del_list", kwargs={"list_id": tlist.id, "list_slug": tlist.slug})
response = admin_client.get(url)
assert response.status_code == 200
def test_view_add_list(todo_setup, admin_client):
- url = reverse('todo:add_list')
+ url = reverse("todo:add_list")
response = admin_client.get(url)
assert response.status_code == 200
def test_view_task_detail(todo_setup, admin_client):
task = Task.objects.first()
- url = reverse('todo:task_detail', kwargs={'task_id': task.id})
+ url = reverse("todo:task_detail", kwargs={"task_id": task.id})
response = admin_client.get(url)
assert response.status_code == 200
def test_view_search(todo_setup, admin_client):
- url = reverse('todo:search')
+ url = reverse("todo:search")
response = admin_client.get(url)
assert response.status_code == 200
@@ -100,11 +103,11 @@ def test_no_javascript_in_task_note(todo_setup, client):
"priority": 10,
"title": title,
"note": note,
- 'add_edit_task': 'Submit'
+ "add_edit_task": "Submit",
}
- client.login(username='u2', password="password")
- url = reverse('todo:list_detail', kwargs={"list_id": task_list.id, "list_slug": task_list.slug})
+ client.login(username="u2", password="password")
+ url = reverse("todo:list_detail", kwargs={"list_id": task_list.id, "list_slug": task_list.slug})
response = client.post(url, data)
assert response.status_code == 302
@@ -118,7 +121,7 @@ def test_no_javascript_in_task_note(todo_setup, client):
@pytest.mark.django_db
def test_no_javascript_in_comments(todo_setup, client):
user = get_user_model().objects.get(username="u2")
- client.login(username='u2', password="password")
+ client.login(username="u2", password="password")
task = Task.objects.first()
task.created_by = user
@@ -127,11 +130,8 @@ def test_no_javascript_in_comments(todo_setup, client):
user.groups.add(task.task_list.group)
comment = "foo bar"
- data = {
- "comment-body": comment,
- "add_comment": 'Submit'
- }
- url = reverse('todo:task_detail', kwargs={"task_id": task.id})
+ data = {"comment-body": comment, "add_comment": "Submit"}
+ url = reverse("todo:task_detail", kwargs={"task_id": task.id})
response = client.post(url, data)
assert response.status_code == 200
@@ -152,7 +152,7 @@ These exercise our custom @staff_only decorator without calling that function ex
def test_view_add_list_nonadmin(todo_setup, client):
- url = reverse('todo:add_list')
+ url = reverse("todo:add_list")
client.login(username="you", password="password")
response = client.get(url)
assert response.status_code == 403
@@ -160,7 +160,7 @@ def test_view_add_list_nonadmin(todo_setup, client):
def test_view_del_list_nonadmin(todo_setup, client):
tlist = TaskList.objects.get(slug="zip")
- url = reverse('todo:del_list', kwargs={'list_id': tlist.id, 'list_slug': tlist.slug})
+ url = reverse("todo:del_list", kwargs={"list_id": tlist.id, "list_slug": tlist.slug})
client.login(username="you", password="password")
response = client.get(url)
assert response.status_code == 403
@@ -170,7 +170,7 @@ def test_view_list_mine(todo_setup, client):
"""View a list in a group I belong to.
"""
tlist = TaskList.objects.get(slug="zip") # User u1 is in this group's list
- url = reverse('todo:list_detail', kwargs={'list_id': tlist.id, 'list_slug': tlist.slug})
+ url = reverse("todo:list_detail", kwargs={"list_id": tlist.id, "list_slug": tlist.slug})
client.login(username="u1", password="password")
response = client.get(url)
assert response.status_code == 200
@@ -180,7 +180,7 @@ def test_view_list_not_mine(todo_setup, client):
"""View a list in a group I don't belong to.
"""
tlist = TaskList.objects.get(slug="zip") # User u1 is in this group, user u2 is not.
- url = reverse('todo:list_detail', kwargs={'list_id': tlist.id, 'list_slug': tlist.slug})
+ url = reverse("todo:list_detail", kwargs={"list_id": tlist.id, "list_slug": tlist.slug})
client.login(username="u2", password="password")
response = client.get(url)
assert response.status_code == 403
@@ -190,7 +190,7 @@ def test_view_task_mine(todo_setup, client):
# Users can always view their own tasks
task = Task.objects.filter(created_by__username="u1").first()
client.login(username="u1", password="password")
- url = reverse('todo:task_detail', kwargs={'task_id': task.id})
+ url = reverse("todo:task_detail", kwargs={"task_id": task.id})
response = client.get(url)
assert response.status_code == 200
@@ -205,7 +205,7 @@ def test_view_task_my_group(todo_setup, client, django_user_model):
# Now u2 should be able to view one of u1's tasks.
task = Task.objects.filter(created_by__username="u1").first()
- url = reverse('todo:task_detail', kwargs={'task_id': task.id})
+ url = reverse("todo:task_detail", kwargs={"task_id": task.id})
client.login(username="u2", password="password")
response = client.get(url)
assert response.status_code == 200
@@ -215,7 +215,8 @@ def test_view_task_not_in_my_group(todo_setup, client):
# User canNOT view a task that isn't theirs if the two users are not in a shared group.
# For this we can use the fixture data as-is.
task = Task.objects.filter(created_by__username="u1").first()
- url = reverse('todo:task_detail', kwargs={'task_id': task.id})
+ url = reverse("todo:task_detail", kwargs={"task_id": task.id})
client.login(username="u2", password="password")
response = client.get(url)
assert response.status_code == 403
+
diff --git a/todo/utils.py b/todo/utils.py
index 1075481..f2ac6e2 100644
--- a/todo/utils.py
+++ b/todo/utils.py
@@ -11,24 +11,30 @@ def send_notify_mail(new_task):
if not new_task.assigned_to == new_task.created_by:
current_site = Site.objects.get_current()
- email_subject = render_to_string("todo/email/assigned_subject.txt", {'task': new_task})
+ email_subject = render_to_string("todo/email/assigned_subject.txt", {"task": new_task})
email_body = render_to_string(
- "todo/email/assigned_body.txt",
- {'task': new_task, 'site': current_site, })
+ "todo/email/assigned_body.txt", {"task": new_task, "site": current_site}
+ )
send_mail(
- email_subject, email_body, new_task.created_by.email,
- [new_task.assigned_to.email], fail_silently=False)
+ email_subject,
+ email_body,
+ new_task.created_by.email,
+ [new_task.assigned_to.email],
+ fail_silently=False,
+ )
def send_email_to_thread_participants(task, msg_body, user, subject=None):
# Notify all previous commentors on a Task about a new comment.
current_site = Site.objects.get_current()
- email_subject = subject if subject else render_to_string("todo/email/assigned_subject.txt", {'task': task})
+ email_subject = (
+ subject if subject else render_to_string("todo/email/assigned_subject.txt", {"task": task})
+ )
email_body = render_to_string(
"todo/email/newcomment_body.txt",
- {'task': task, 'body': msg_body, 'site': current_site, 'user': user}
+ {"task": task, "body": msg_body, "site": current_site, "user": user},
)
# Get list of all thread participants - everyone who has commented, plus task creator.
diff --git a/todo/views.py b/todo/views.py
index ba01504..c4ddbda 100644
--- a/todo/views.py
+++ b/todo/views.py
@@ -20,10 +20,7 @@ from django.views.decorators.csrf import csrf_exempt
from todo.forms import AddTaskListForm, AddEditTaskForm, AddExternalTaskForm, SearchForm
from todo.models import Task, TaskList, Comment
-from todo.utils import (
- send_notify_mail,
- send_email_to_thread_participants,
- )
+from todo.utils import send_notify_mail, send_email_to_thread_participants
def staff_only(function):
@@ -31,6 +28,7 @@ def staff_only(function):
Custom view decorator allows us to raise 403 on insufficient permissions,
rather than redirect user to login view.
"""
+
def wrap(request, *args, **kwargs):
if request.user.is_staff:
return function(request, *args, **kwargs)
@@ -52,13 +50,18 @@ def list_lists(request) -> HttpResponse:
# Make sure user belongs to at least one group.
if request.user.groups.all().count() == 0:
- messages.warning(request, "You do not yet belong to any groups. Ask your administrator to add you to one.")
+ messages.warning(
+ request,
+ "You do not yet belong to any groups. Ask your administrator to add you to one.",
+ )
# Superusers see all lists
if request.user.is_superuser:
- lists = TaskList.objects.all().order_by('group', 'name')
+ lists = TaskList.objects.all().order_by("group", "name")
else:
- lists = TaskList.objects.filter(group__in=request.user.groups.all()).order_by('group', 'name')
+ lists = TaskList.objects.filter(group__in=request.user.groups.all()).order_by(
+ "group", "name"
+ )
list_count = lists.count()
@@ -66,17 +69,21 @@ def list_lists(request) -> HttpResponse:
if request.user.is_superuser:
task_count = Task.objects.filter(completed=0).count()
else:
- task_count = Task.objects.filter(completed=0).filter(task_list__group__in=request.user.groups.all()).count()
+ task_count = (
+ Task.objects.filter(completed=0)
+ .filter(task_list__group__in=request.user.groups.all())
+ .count()
+ )
context = {
- "lists": lists,
- "thedate": thedate,
- "searchform": searchform,
- "list_count": list_count,
- "task_count": task_count,
+ "lists": lists,
+ "thedate": thedate,
+ "searchform": searchform,
+ "list_count": list_count,
+ "task_count": task_count,
}
- return render(request, 'todo/list_lists.html', context)
+ return render(request, "todo/list_lists.html", context)
@staff_only
@@ -92,10 +99,10 @@ def del_list(request, list_id: int, list_slug: str) -> HttpResponse:
if task_list.group not in request.user.groups.all() and not request.user.is_staff:
raise PermissionDenied
- if request.method == 'POST':
+ if request.method == "POST":
TaskList.objects.get(id=task_list.id).delete()
messages.success(request, "{list_name} is gone.".format(list_name=task_list.name))
- return redirect('todo:lists')
+ return redirect("todo:lists")
else:
task_count_done = Task.objects.filter(task_list=task_list.id, completed=True).count()
task_count_undone = Task.objects.filter(task_list=task_list.id, completed=False).count()
@@ -108,7 +115,7 @@ def del_list(request, list_id: int, list_slug: str) -> HttpResponse:
"task_count_total": task_count_total,
}
- return render(request, 'todo/del_list.html', context)
+ return render(request, "todo/del_list.html", context)
@login_required
@@ -141,33 +148,36 @@ def list_detail(request, list_id=None, list_slug=None, view_completed=False):
# Add New Task Form
# ######################
- if request.POST.getlist('add_edit_task'):
- form = AddEditTaskForm(request.user, request.POST, initial={
- 'assigned_to': request.user.id,
- 'priority': 999,
- 'task_list': task_list
- })
+ if request.POST.getlist("add_edit_task"):
+ form = AddEditTaskForm(
+ request.user,
+ request.POST,
+ initial={"assigned_to": request.user.id, "priority": 999, "task_list": task_list},
+ )
if form.is_valid():
new_task = form.save(commit=False)
new_task.created_date = timezone.now()
- new_task.note = bleach.clean(form.cleaned_data['note'], strip=True)
+ new_task.note = bleach.clean(form.cleaned_data["note"], strip=True)
form.save()
# Send email alert only if Notify checkbox is checked AND assignee is not same as the submitter
- if "notify" in request.POST and new_task.assigned_to and new_task.assigned_to != request.user:
+ if (
+ "notify" in request.POST
+ and new_task.assigned_to
+ and new_task.assigned_to != request.user
+ ):
send_notify_mail(new_task)
- messages.success(request, "New task \"{t}\" has been added.".format(t=new_task.title))
+ messages.success(request, 'New task "{t}" has been added.'.format(t=new_task.title))
return redirect(request.path)
else:
# Don't allow adding new tasks on some views
- if list_slug not in ["mine", "recent-add", "recent-complete", ]:
- form = AddEditTaskForm(request.user, initial={
- 'assigned_to': request.user.id,
- 'priority': 999,
- 'task_list': task_list,
- })
+ if list_slug not in ["mine", "recent-add", "recent-complete"]:
+ form = AddEditTaskForm(
+ request.user,
+ initial={"assigned_to": request.user.id, "priority": 999, "task_list": task_list},
+ )
context = {
"list_id": list_id,
@@ -178,7 +188,7 @@ def list_detail(request, list_id=None, list_slug=None, view_completed=False):
"view_completed": view_completed,
}
- return render(request, 'todo/list_detail.html', context)
+ return render(request, "todo/list_detail.html", context)
@login_required
@@ -195,52 +205,54 @@ def task_detail(request, task_id: int) -> HttpResponse:
raise PermissionDenied
# Save submitted comments
- if request.POST.get('add_comment'):
+ if request.POST.get("add_comment"):
Comment.objects.create(
author=request.user,
task=task,
- body=bleach.clean(request.POST['comment-body'], strip=True),
+ body=bleach.clean(request.POST["comment-body"], strip=True),
)
send_email_to_thread_participants(
- task, request.POST['comment-body'], request.user,
- subject='New comment posted on task "{}"'.format(task.title))
+ task,
+ request.POST["comment-body"],
+ request.user,
+ subject='New comment posted on task "{}"'.format(task.title),
+ )
messages.success(request, "Comment posted. Notification email sent to thread participants.")
# Save task edits
- if request.POST.get('add_edit_task'):
- form = AddEditTaskForm(request.user, request.POST, instance=task, initial={'task_list': task.task_list})
+ if request.POST.get("add_edit_task"):
+ form = AddEditTaskForm(
+ request.user, request.POST, instance=task, initial={"task_list": task.task_list}
+ )
if form.is_valid():
item = form.save(commit=False)
- item.note = bleach.clean(form.cleaned_data['note'], strip=True)
+ item.note = bleach.clean(form.cleaned_data["note"], strip=True)
item.save()
messages.success(request, "The task has been edited.")
- return redirect('todo:list_detail', list_id=task.task_list.id, list_slug=task.task_list.slug)
+ return redirect(
+ "todo:list_detail", list_id=task.task_list.id, list_slug=task.task_list.slug
+ )
else:
- form = AddEditTaskForm(request.user, instance=task, initial={'task_list': task.task_list})
+ form = AddEditTaskForm(request.user, instance=task, initial={"task_list": task.task_list})
# Mark complete
- if request.POST.get('toggle_done'):
- results_changed = toggle_done([task.id, ])
+ if request.POST.get("toggle_done"):
+ results_changed = toggle_done([task.id])
for res in results_changed:
messages.success(request, res)
- return redirect('todo:task_detail', task_id=task.id,)
+ return redirect("todo:task_detail", task_id=task.id)
if task.due_date:
thedate = task.due_date
else:
thedate = datetime.datetime.now()
- context = {
- "task": task,
- "comment_list": comment_list,
- "form": form,
- "thedate": thedate,
- }
+ context = {"task": task, "comment_list": comment_list, "form": form, "thedate": thedate}
- return render(request, 'todo/task_detail.html', context)
+ return render(request, "todo/task_detail.html", context)
@csrf_exempt
@@ -248,7 +260,7 @@ def task_detail(request, task_id: int) -> HttpResponse:
def reorder_tasks(request) -> HttpResponse:
"""Handle task re-ordering (priorities) from JQuery drag/drop in list_detail.html
"""
- newtasklist = request.POST.getlist('tasktable[]')
+ newtasklist = request.POST.getlist("tasktable[]")
if newtasklist:
# First task in received list is always empty - remove it
del newtasklist[0]
@@ -280,24 +292,23 @@ def add_list(request) -> HttpResponse:
newlist.slug = slugify(newlist.name)
newlist.save()
messages.success(request, "A new list has been added.")
- return redirect('todo:lists')
+ return redirect("todo:lists")
except IntegrityError:
messages.warning(
request,
"There was a problem saving the new list. "
- "Most likely a list with the same name in the same group already exists.")
+ "Most likely a list with the same name in the same group already exists.",
+ )
else:
if request.user.groups.all().count() == 1:
form = AddTaskListForm(request.user, initial={"group": request.user.groups.all()[0]})
else:
form = AddTaskListForm(request.user)
- context = {
- "form": form,
- }
+ context = {"form": form}
- return render(request, 'todo/add_list.html', context)
+ return render(request, "todo/add_list.html", context)
@login_required
@@ -306,36 +317,32 @@ def search(request) -> HttpResponse:
"""
if request.GET:
- query_string = ''
+ query_string = ""
found_tasks = None
- if ('q' in request.GET) and request.GET['q'].strip():
- query_string = request.GET['q']
+ if ("q" in request.GET) and request.GET["q"].strip():
+ query_string = request.GET["q"]
found_tasks = Task.objects.filter(
- Q(title__icontains=query_string) |
- Q(note__icontains=query_string)
+ Q(title__icontains=query_string) | Q(note__icontains=query_string)
)
else:
# What if they selected the "completed" toggle but didn't enter a query string?
# We still need found_tasks in a queryset so it can be "excluded" below.
found_tasks = Task.objects.all()
- if 'inc_complete' in request.GET:
+ if "inc_complete" in request.GET:
found_tasks = found_tasks.exclude(completed=True)
else:
query_string = None
- found_tasks =None
+ found_tasks = None
# Only include tasks that are in groups of which this user is a member:
if not request.user.is_superuser:
found_tasks = found_tasks.filter(task_list__group__in=request.user.groups.all())
- context = {
- 'query_string': query_string,
- 'found_tasks': found_tasks
- }
- return render(request, 'todo/search_results.html', context)
+ context = {"query_string": query_string, "found_tasks": found_tasks}
+ return render(request, "todo/search_results.html", context)
@login_required
@@ -348,10 +355,14 @@ def external_add(request) -> HttpResponse:
"""
if not settings.TODO_DEFAULT_LIST_SLUG:
- raise RuntimeError("This feature requires TODO_DEFAULT_LIST_SLUG: in settings. See documentation.")
+ raise RuntimeError(
+ "This feature requires TODO_DEFAULT_LIST_SLUG: in settings. See documentation."
+ )
if not TaskList.objects.filter(slug=settings.TODO_DEFAULT_LIST_SLUG).exists():
- raise RuntimeError("There is no TaskList with slug specified for TODO_DEFAULT_LIST_SLUG in settings.")
+ raise RuntimeError(
+ "There is no TaskList with slug specified for TODO_DEFAULT_LIST_SLUG in settings."
+ )
if request.POST:
form = AddExternalTaskForm(request.POST)
@@ -367,26 +378,36 @@ def external_add(request) -> HttpResponse:
# Send email to assignee if we have one
if task.assigned_to:
- email_subject = render_to_string("todo/email/assigned_subject.txt", {'task': task.title})
- email_body = render_to_string("todo/email/assigned_body.txt", {'task': task, 'site': current_site, })
+ email_subject = render_to_string(
+ "todo/email/assigned_subject.txt", {"task": task.title}
+ )
+ email_body = render_to_string(
+ "todo/email/assigned_body.txt", {"task": task, "site": current_site}
+ )
try:
send_mail(
- email_subject, email_body, task.created_by.email,
- [task.assigned_to.email, ], fail_silently=False)
+ email_subject,
+ email_body,
+ task.created_by.email,
+ [task.assigned_to.email],
+ fail_silently=False,
+ )
except ConnectionRefusedError:
- messages.warning(request, "Task saved but mail not sent. Contact your administrator.")
+ messages.warning(
+ request, "Task saved but mail not sent. Contact your administrator."
+ )
- messages.success(request, "Your trouble ticket has been submitted. We'll get back to you soon.")
+ messages.success(
+ request, "Your trouble ticket has been submitted. We'll get back to you soon."
+ )
return redirect(settings.TODO_PUBLIC_SUBMIT_REDIRECT)
else:
- form = AddExternalTaskForm(initial={'priority': 999})
+ form = AddExternalTaskForm(initial={"priority": 999})
- context = {
- "form": form,
- }
+ context = {"form": form}
- return render(request, 'todo/add_task_external.html', context)
+ return render(request, "todo/add_task_external.html", context)
@login_required
@@ -399,9 +420,9 @@ def toggle_done(request, task_id: int) -> HttpResponse:
# Permissions
if not (
- (task.created_by == request.user) or
- (task.assigned_to == request.user) or
- (task.task_list.group in request.user.groups.all())
+ (task.created_by == request.user)
+ or (task.assigned_to == request.user)
+ or (task.task_list.group in request.user.groups.all())
):
raise PermissionDenied
@@ -410,8 +431,9 @@ def toggle_done(request, task_id: int) -> HttpResponse:
task.save()
messages.success(request, "Task status changed for '{}'".format(task.title))
- return redirect(reverse('todo:list_detail', kwargs={"list_id": tlist.id, "list_slug": tlist.slug}))
-
+ return redirect(
+ reverse("todo:list_detail", kwargs={"list_id": tlist.id, "list_slug": tlist.slug})
+ )
@login_required
@@ -424,9 +446,9 @@ def delete_task(request, task_id: int) -> HttpResponse:
# Permissions
if not (
- (task.created_by == request.user) or
- (task.assigned_to == request.user) or
- (task.task_list.group in request.user.groups.all())
+ (task.created_by == request.user)
+ or (task.assigned_to == request.user)
+ or (task.task_list.group in request.user.groups.all())
):
raise PermissionDenied
@@ -434,4 +456,7 @@ def delete_task(request, task_id: int) -> HttpResponse:
task.delete()
messages.success(request, "Task '{}' has been deleted".format(task.title))
- return redirect(reverse('todo:list_detail', kwargs={"list_id": tlist.id, "list_slug": tlist.slug}))
+ return redirect(
+ reverse("todo:list_detail", kwargs={"list_id": tlist.id, "list_slug": tlist.slug})
+ )
+
From 78e9c510bcb5148f6d34419a27fd96bbbd94a921 Mon Sep 17 00:00:00 2001
From: Scot Hacker
Date: Fri, 21 Dec 2018 02:00:36 -0800
Subject: [PATCH 086/210] Split up views into separate modules
---
README.md | 2 +
todo/__init__.py | 2 +-
todo/utils.py | 32 ++-
todo/views.py | 462 ------------------------------------
todo/views/__init__.py | 10 +
todo/views/add_list.py | 42 ++++
todo/views/del_list.py | 42 ++++
todo/views/delete_task.py | 32 +++
todo/views/external_add.py | 77 ++++++
todo/views/list_detail.py | 84 +++++++
todo/views/list_lists.py | 55 +++++
todo/views/reorder_tasks.py | 28 +++
todo/views/search.py | 40 ++++
todo/views/task_detail.py | 76 ++++++
todo/views/toggle_done.py | 37 +++
15 files changed, 557 insertions(+), 464 deletions(-)
delete mode 100644 todo/views.py
create mode 100644 todo/views/__init__.py
create mode 100644 todo/views/add_list.py
create mode 100644 todo/views/del_list.py
create mode 100644 todo/views/delete_task.py
create mode 100644 todo/views/external_add.py
create mode 100644 todo/views/list_detail.py
create mode 100644 todo/views/list_lists.py
create mode 100644 todo/views/reorder_tasks.py
create mode 100644 todo/views/search.py
create mode 100644 todo/views/task_detail.py
create mode 100644 todo/views/toggle_done.py
diff --git a/README.md b/README.md
index 5e63916..ffecec2 100644
--- a/README.md
+++ b/README.md
@@ -167,6 +167,8 @@ The previous `tox` system was removed with the v2 release, since we no longer ai
# Version History
+**2.1.1** Split up views into separate modules.
+
**2.1.0** December 2018: No longer allowing Javascript in task or comment bodies. Misc bug fixes.
**2.0.3** April 2018: Bump production status in setup.py
diff --git a/todo/__init__.py b/todo/__init__.py
index 62af3e3..ae92868 100644
--- a/todo/__init__.py
+++ b/todo/__init__.py
@@ -1,7 +1,7 @@
"""
A multi-user, multi-group task management and assignment system for Django.
"""
-__version__ = '2.1.0'
+__version__ = '2.1.1'
__author__ = 'Scot Hacker'
__email__ = 'shacker@birdhouse.org'
diff --git a/todo/utils.py b/todo/utils.py
index f2ac6e2..6e85b06 100644
--- a/todo/utils.py
+++ b/todo/utils.py
@@ -1,8 +1,26 @@
from django.contrib.sites.models import Site
+from django.core.exceptions import PermissionDenied
from django.core.mail import send_mail
from django.template.loader import render_to_string
-from todo.models import Comment
+from todo.models import Comment, Task
+
+
+def staff_only(function):
+ """
+ Custom view decorator allows us to raise 403 on insufficient permissions,
+ rather than redirect user to login view.
+ """
+
+ def wrap(request, *args, **kwargs):
+ if request.user.is_staff:
+ return function(request, *args, **kwargs)
+ else:
+ raise PermissionDenied
+
+ wrap.__doc__ = function.__doc__
+ wrap.__name__ = function.__name__
+ return wrap
def send_notify_mail(new_task):
@@ -44,3 +62,15 @@ def send_email_to_thread_participants(task, msg_body, user, subject=None):
recip_list = list(set(recip_list)) # Eliminate duplicates
send_mail(email_subject, email_body, task.created_by.email, recip_list, fail_silently=False)
+
+
+def toggle_task_completed(task_id: int) -> bool:
+ try:
+ task = Task.objects.get(id=task_id)
+ task.completed = not task.completed
+ task.save()
+ return True
+ except Task.DoesNotExist:
+ # FIXME proper log message
+ print("task not found")
+ return False
diff --git a/todo/views.py b/todo/views.py
deleted file mode 100644
index c4ddbda..0000000
--- a/todo/views.py
+++ /dev/null
@@ -1,462 +0,0 @@
-import datetime
-import bleach
-
-from django.conf import settings
-from django.contrib import messages
-from django.contrib.auth.decorators import login_required
-from django.contrib.auth.models import User
-from django.contrib.sites.models import Site
-from django.core.exceptions import PermissionDenied
-from django.core.mail import send_mail
-from django.db import IntegrityError
-from django.db.models import Q
-from django.http import HttpResponse
-from django.shortcuts import get_object_or_404, render, redirect
-from django.template.loader import render_to_string
-from django.urls import reverse
-from django.utils import timezone
-from django.utils.text import slugify
-from django.views.decorators.csrf import csrf_exempt
-
-from todo.forms import AddTaskListForm, AddEditTaskForm, AddExternalTaskForm, SearchForm
-from todo.models import Task, TaskList, Comment
-from todo.utils import send_notify_mail, send_email_to_thread_participants
-
-
-def staff_only(function):
- """
- Custom view decorator allows us to raise 403 on insufficient permissions,
- rather than redirect user to login view.
- """
-
- def wrap(request, *args, **kwargs):
- if request.user.is_staff:
- return function(request, *args, **kwargs)
- else:
- raise PermissionDenied
-
- wrap.__doc__ = function.__doc__
- wrap.__name__ = function.__name__
- return wrap
-
-
-@login_required
-def list_lists(request) -> HttpResponse:
- """Homepage view - list of lists a user can view, and ability to add a list.
- """
-
- thedate = datetime.datetime.now()
- searchform = SearchForm(auto_id=False)
-
- # Make sure user belongs to at least one group.
- if request.user.groups.all().count() == 0:
- messages.warning(
- request,
- "You do not yet belong to any groups. Ask your administrator to add you to one.",
- )
-
- # Superusers see all lists
- if request.user.is_superuser:
- lists = TaskList.objects.all().order_by("group", "name")
- else:
- lists = TaskList.objects.filter(group__in=request.user.groups.all()).order_by(
- "group", "name"
- )
-
- list_count = lists.count()
-
- # superusers see all lists, so count shouldn't filter by just lists the admin belongs to
- if request.user.is_superuser:
- task_count = Task.objects.filter(completed=0).count()
- else:
- task_count = (
- Task.objects.filter(completed=0)
- .filter(task_list__group__in=request.user.groups.all())
- .count()
- )
-
- context = {
- "lists": lists,
- "thedate": thedate,
- "searchform": searchform,
- "list_count": list_count,
- "task_count": task_count,
- }
-
- return render(request, "todo/list_lists.html", context)
-
-
-@staff_only
-@login_required
-def del_list(request, list_id: int, list_slug: str) -> HttpResponse:
- """Delete an entire list. Danger Will Robinson! Only staff members should be allowed to access this view.
- """
- task_list = get_object_or_404(TaskList, id=list_id)
-
- # Ensure user has permission to delete list. Admins can delete all lists.
- # Get the group this list belongs to, and check whether current user is a member of that group.
- # FIXME: This means any group member can delete lists, which is probably too permissive.
- if task_list.group not in request.user.groups.all() and not request.user.is_staff:
- raise PermissionDenied
-
- if request.method == "POST":
- TaskList.objects.get(id=task_list.id).delete()
- messages.success(request, "{list_name} is gone.".format(list_name=task_list.name))
- return redirect("todo:lists")
- else:
- task_count_done = Task.objects.filter(task_list=task_list.id, completed=True).count()
- task_count_undone = Task.objects.filter(task_list=task_list.id, completed=False).count()
- task_count_total = Task.objects.filter(task_list=task_list.id).count()
-
- context = {
- "task_list": task_list,
- "task_count_done": task_count_done,
- "task_count_undone": task_count_undone,
- "task_count_total": task_count_total,
- }
-
- return render(request, "todo/del_list.html", context)
-
-
-@login_required
-def list_detail(request, list_id=None, list_slug=None, view_completed=False):
- """Display and manage tasks in a todo list.
- """
-
- # Defaults
- task_list = None
- form = None
-
- # Which tasks to show on this list view?
- if list_slug == "mine":
- tasks = Task.objects.filter(assigned_to=request.user)
-
- else:
- # Show a specific list, ensuring permissions.
- task_list = get_object_or_404(TaskList, id=list_id)
- if task_list.group not in request.user.groups.all() and not request.user.is_staff:
- raise PermissionDenied
- tasks = Task.objects.filter(task_list=task_list.id)
-
- # Additional filtering
- if view_completed:
- tasks = tasks.filter(completed=True)
- else:
- tasks = tasks.filter(completed=False)
-
- # ######################
- # Add New Task Form
- # ######################
-
- if request.POST.getlist("add_edit_task"):
- form = AddEditTaskForm(
- request.user,
- request.POST,
- initial={"assigned_to": request.user.id, "priority": 999, "task_list": task_list},
- )
-
- if form.is_valid():
- new_task = form.save(commit=False)
- new_task.created_date = timezone.now()
- new_task.note = bleach.clean(form.cleaned_data["note"], strip=True)
- form.save()
-
- # Send email alert only if Notify checkbox is checked AND assignee is not same as the submitter
- if (
- "notify" in request.POST
- and new_task.assigned_to
- and new_task.assigned_to != request.user
- ):
- send_notify_mail(new_task)
-
- messages.success(request, 'New task "{t}" has been added.'.format(t=new_task.title))
- return redirect(request.path)
- else:
- # Don't allow adding new tasks on some views
- if list_slug not in ["mine", "recent-add", "recent-complete"]:
- form = AddEditTaskForm(
- request.user,
- initial={"assigned_to": request.user.id, "priority": 999, "task_list": task_list},
- )
-
- context = {
- "list_id": list_id,
- "list_slug": list_slug,
- "task_list": task_list,
- "form": form,
- "tasks": tasks,
- "view_completed": view_completed,
- }
-
- return render(request, "todo/list_detail.html", context)
-
-
-@login_required
-def task_detail(request, task_id: int) -> HttpResponse:
- """View task details. Allow task details to be edited. Process new comments on task.
- """
-
- task = get_object_or_404(Task, pk=task_id)
- comment_list = Comment.objects.filter(task=task_id)
-
- # Ensure user has permission to view task. Admins can view all tasks.
- # Get the group this task belongs to, and check whether current user is a member of that group.
- if task.task_list.group not in request.user.groups.all() and not request.user.is_staff:
- raise PermissionDenied
-
- # Save submitted comments
- if request.POST.get("add_comment"):
- Comment.objects.create(
- author=request.user,
- task=task,
- body=bleach.clean(request.POST["comment-body"], strip=True),
- )
-
- send_email_to_thread_participants(
- task,
- request.POST["comment-body"],
- request.user,
- subject='New comment posted on task "{}"'.format(task.title),
- )
- messages.success(request, "Comment posted. Notification email sent to thread participants.")
-
- # Save task edits
- if request.POST.get("add_edit_task"):
- form = AddEditTaskForm(
- request.user, request.POST, instance=task, initial={"task_list": task.task_list}
- )
-
- if form.is_valid():
- item = form.save(commit=False)
- item.note = bleach.clean(form.cleaned_data["note"], strip=True)
- item.save()
- messages.success(request, "The task has been edited.")
- return redirect(
- "todo:list_detail", list_id=task.task_list.id, list_slug=task.task_list.slug
- )
- else:
- form = AddEditTaskForm(request.user, instance=task, initial={"task_list": task.task_list})
-
- # Mark complete
- if request.POST.get("toggle_done"):
- results_changed = toggle_done([task.id])
- for res in results_changed:
- messages.success(request, res)
-
- return redirect("todo:task_detail", task_id=task.id)
-
- if task.due_date:
- thedate = task.due_date
- else:
- thedate = datetime.datetime.now()
-
- context = {"task": task, "comment_list": comment_list, "form": form, "thedate": thedate}
-
- return render(request, "todo/task_detail.html", context)
-
-
-@csrf_exempt
-@login_required
-def reorder_tasks(request) -> HttpResponse:
- """Handle task re-ordering (priorities) from JQuery drag/drop in list_detail.html
- """
- newtasklist = request.POST.getlist("tasktable[]")
- if newtasklist:
- # First task in received list is always empty - remove it
- del newtasklist[0]
-
- # Re-prioritize each task in list
- i = 1
- for id in newtasklist:
- task = Task.objects.get(pk=id)
- task.priority = i
- task.save()
- i += 1
-
- # 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)
-
-
-@staff_only
-@login_required
-def add_list(request) -> HttpResponse:
- """Allow users to add a new todo list to the group they're in.
- """
-
- if request.POST:
- form = AddTaskListForm(request.user, request.POST)
- if form.is_valid():
- try:
- newlist = form.save(commit=False)
- newlist.slug = slugify(newlist.name)
- newlist.save()
- messages.success(request, "A new list has been added.")
- return redirect("todo:lists")
-
- except IntegrityError:
- messages.warning(
- request,
- "There was a problem saving the new list. "
- "Most likely a list with the same name in the same group already exists.",
- )
- else:
- if request.user.groups.all().count() == 1:
- form = AddTaskListForm(request.user, initial={"group": request.user.groups.all()[0]})
- else:
- form = AddTaskListForm(request.user)
-
- context = {"form": form}
-
- return render(request, "todo/add_list.html", context)
-
-
-@login_required
-def search(request) -> HttpResponse:
- """Search for tasks user has permission to see.
- """
- if request.GET:
-
- query_string = ""
- found_tasks = None
- if ("q" in request.GET) and request.GET["q"].strip():
- query_string = request.GET["q"]
-
- found_tasks = Task.objects.filter(
- Q(title__icontains=query_string) | Q(note__icontains=query_string)
- )
- else:
- # What if they selected the "completed" toggle but didn't enter a query string?
- # We still need found_tasks in a queryset so it can be "excluded" below.
- found_tasks = Task.objects.all()
-
- if "inc_complete" in request.GET:
- found_tasks = found_tasks.exclude(completed=True)
-
- else:
- query_string = None
- found_tasks = None
-
- # Only include tasks that are in groups of which this user is a member:
- if not request.user.is_superuser:
- found_tasks = found_tasks.filter(task_list__group__in=request.user.groups.all())
-
- context = {"query_string": query_string, "found_tasks": found_tasks}
- return render(request, "todo/search_results.html", context)
-
-
-@login_required
-def external_add(request) -> HttpResponse:
- """Allow authenticated users who don't have access to the rest of the ticket system to file a ticket
- in the list specified in settings (e.g. django-todo can be used a ticket filing system for a school, where
- students can file tickets without access to the rest of the todo system).
-
- Publicly filed tickets are unassigned unless settings.DEFAULT_ASSIGNEE exists.
- """
-
- if not settings.TODO_DEFAULT_LIST_SLUG:
- raise RuntimeError(
- "This feature requires TODO_DEFAULT_LIST_SLUG: in settings. See documentation."
- )
-
- if not TaskList.objects.filter(slug=settings.TODO_DEFAULT_LIST_SLUG).exists():
- raise RuntimeError(
- "There is no TaskList with slug specified for TODO_DEFAULT_LIST_SLUG in settings."
- )
-
- if request.POST:
- form = AddExternalTaskForm(request.POST)
-
- if form.is_valid():
- current_site = Site.objects.get_current()
- task = form.save(commit=False)
- task.task_list = TaskList.objects.get(slug=settings.TODO_DEFAULT_LIST_SLUG)
- task.created_by = request.user
- if settings.TODO_DEFAULT_ASSIGNEE:
- task.assigned_to = User.objects.get(username=settings.TODO_DEFAULT_ASSIGNEE)
- task.save()
-
- # Send email to assignee if we have one
- if task.assigned_to:
- email_subject = render_to_string(
- "todo/email/assigned_subject.txt", {"task": task.title}
- )
- email_body = render_to_string(
- "todo/email/assigned_body.txt", {"task": task, "site": current_site}
- )
- try:
- send_mail(
- email_subject,
- email_body,
- task.created_by.email,
- [task.assigned_to.email],
- fail_silently=False,
- )
- except ConnectionRefusedError:
- messages.warning(
- request, "Task saved but mail not sent. Contact your administrator."
- )
-
- messages.success(
- request, "Your trouble ticket has been submitted. We'll get back to you soon."
- )
- return redirect(settings.TODO_PUBLIC_SUBMIT_REDIRECT)
-
- else:
- form = AddExternalTaskForm(initial={"priority": 999})
-
- context = {"form": form}
-
- return render(request, "todo/add_task_external.html", context)
-
-
-@login_required
-def toggle_done(request, task_id: int) -> HttpResponse:
- """Toggle the completed status of a task from done to undone, or vice versa.
- Redirect to the list from which the task came.
- """
-
- task = get_object_or_404(Task, pk=task_id)
-
- # Permissions
- if not (
- (task.created_by == request.user)
- or (task.assigned_to == request.user)
- or (task.task_list.group in request.user.groups.all())
- ):
- raise PermissionDenied
-
- tlist = task.task_list
- task.completed = not task.completed
- task.save()
-
- messages.success(request, "Task status changed for '{}'".format(task.title))
- return redirect(
- reverse("todo:list_detail", kwargs={"list_id": tlist.id, "list_slug": tlist.slug})
- )
-
-
-@login_required
-def delete_task(request, task_id: int) -> HttpResponse:
- """Delete specified task.
- Redirect to the list from which the task came.
- """
-
- task = get_object_or_404(Task, pk=task_id)
-
- # Permissions
- if not (
- (task.created_by == request.user)
- or (task.assigned_to == request.user)
- or (task.task_list.group in request.user.groups.all())
- ):
- raise PermissionDenied
-
- tlist = task.task_list
- task.delete()
-
- messages.success(request, "Task '{}' has been deleted".format(task.title))
- return redirect(
- reverse("todo:list_detail", kwargs={"list_id": tlist.id, "list_slug": tlist.slug})
- )
-
diff --git a/todo/views/__init__.py b/todo/views/__init__.py
new file mode 100644
index 0000000..c36bb65
--- /dev/null
+++ b/todo/views/__init__.py
@@ -0,0 +1,10 @@
+from todo.views.add_list import add_list # noqa: F401
+from todo.views.del_list import del_list # noqa: F401
+from todo.views.delete_task import delete_task # noqa: F401
+from todo.views.external_add import external_add # noqa: F401
+from todo.views.list_detail import list_detail # noqa: F401
+from todo.views.list_lists import list_lists # noqa: F401
+from todo.views.reorder_tasks import reorder_tasks # noqa: F401
+from todo.views.search import search # noqa: F401
+from todo.views.task_detail import task_detail # noqa: F401
+from todo.views.toggle_done import toggle_done # noqa: F401
diff --git a/todo/views/add_list.py b/todo/views/add_list.py
new file mode 100644
index 0000000..e8a70ae
--- /dev/null
+++ b/todo/views/add_list.py
@@ -0,0 +1,42 @@
+from django.contrib import messages
+from django.contrib.auth.decorators import login_required
+from django.db import IntegrityError
+from django.http import HttpResponse
+from django.shortcuts import redirect, render
+from django.utils.text import slugify
+
+from todo.forms import AddTaskListForm
+from todo.utils import staff_only
+
+
+@staff_only
+@login_required
+def add_list(request) -> HttpResponse:
+ """Allow users to add a new todo list to the group they're in.
+ """
+
+ if request.POST:
+ form = AddTaskListForm(request.user, request.POST)
+ if form.is_valid():
+ try:
+ newlist = form.save(commit=False)
+ newlist.slug = slugify(newlist.name)
+ newlist.save()
+ messages.success(request, "A new list has been added.")
+ return redirect("todo:lists")
+
+ except IntegrityError:
+ messages.warning(
+ request,
+ "There was a problem saving the new list. "
+ "Most likely a list with the same name in the same group already exists.",
+ )
+ else:
+ if request.user.groups.all().count() == 1:
+ form = AddTaskListForm(request.user, initial={"group": request.user.groups.all()[0]})
+ else:
+ form = AddTaskListForm(request.user)
+
+ context = {"form": form}
+
+ return render(request, "todo/add_list.html", context)
diff --git a/todo/views/del_list.py b/todo/views/del_list.py
new file mode 100644
index 0000000..d2a9164
--- /dev/null
+++ b/todo/views/del_list.py
@@ -0,0 +1,42 @@
+import datetime
+
+from django.contrib import messages
+from django.contrib.auth.decorators import login_required
+from django.http import HttpResponse
+from django.shortcuts import render, redirect, get_object_or_404
+from django.core.exceptions import PermissionDenied
+
+from todo.models import Task, TaskList
+from todo.utils import staff_only
+
+
+@staff_only
+@login_required
+def del_list(request, list_id: int, list_slug: str) -> HttpResponse:
+ """Delete an entire list. Only staff members should be allowed to access this view.
+ """
+ task_list = get_object_or_404(TaskList, id=list_id)
+
+ # Ensure user has permission to delete list. Admins can delete all lists.
+ # Get the group this list belongs to, and check whether current user is a member of that group.
+ # FIXME: This means any group member can delete lists, which is probably too permissive.
+ if task_list.group not in request.user.groups.all() and not request.user.is_staff:
+ raise PermissionDenied
+
+ if request.method == "POST":
+ TaskList.objects.get(id=task_list.id).delete()
+ messages.success(request, "{list_name} is gone.".format(list_name=task_list.name))
+ return redirect("todo:lists")
+ else:
+ task_count_done = Task.objects.filter(task_list=task_list.id, completed=True).count()
+ task_count_undone = Task.objects.filter(task_list=task_list.id, completed=False).count()
+ task_count_total = Task.objects.filter(task_list=task_list.id).count()
+
+ context = {
+ "task_list": task_list,
+ "task_count_done": task_count_done,
+ "task_count_undone": task_count_undone,
+ "task_count_total": task_count_total,
+ }
+
+ return render(request, "todo/del_list.html", context)
diff --git a/todo/views/delete_task.py b/todo/views/delete_task.py
new file mode 100644
index 0000000..a7c1334
--- /dev/null
+++ b/todo/views/delete_task.py
@@ -0,0 +1,32 @@
+from django.contrib import messages
+from django.contrib.auth.decorators import login_required
+from django.core.exceptions import PermissionDenied
+from django.http import HttpResponse
+from django.shortcuts import get_object_or_404, redirect
+from django.urls import reverse
+
+from todo.models import Task
+
+@login_required
+def delete_task(request, task_id: int) -> HttpResponse:
+ """Delete specified task.
+ Redirect to the list from which the task came.
+ """
+
+ task = get_object_or_404(Task, pk=task_id)
+
+ # Permissions
+ if not (
+ (task.created_by == request.user)
+ or (task.assigned_to == request.user)
+ or (task.task_list.group in request.user.groups.all())
+ ):
+ raise PermissionDenied
+
+ tlist = task.task_list
+ task.delete()
+
+ messages.success(request, "Task '{}' has been deleted".format(task.title))
+ return redirect(
+ reverse("todo:list_detail", kwargs={"list_id": tlist.id, "list_slug": tlist.slug})
+ )
diff --git a/todo/views/external_add.py b/todo/views/external_add.py
new file mode 100644
index 0000000..c8fdac1
--- /dev/null
+++ b/todo/views/external_add.py
@@ -0,0 +1,77 @@
+from django.conf import settings
+from django.contrib import messages
+from django.contrib.auth.decorators import login_required
+from django.contrib.auth.models import User
+from django.contrib.sites.models import Site
+from django.core.mail import send_mail
+from django.http import HttpResponse
+from django.shortcuts import redirect, render
+from django.template.loader import render_to_string
+
+from todo.forms import AddExternalTaskForm
+from todo.models import TaskList
+
+
+@login_required
+def external_add(request) -> HttpResponse:
+ """Allow authenticated users who don't have access to the rest of the ticket system to file a ticket
+ in the list specified in settings (e.g. django-todo can be used a ticket filing system for a school, where
+ students can file tickets without access to the rest of the todo system).
+
+ Publicly filed tickets are unassigned unless settings.DEFAULT_ASSIGNEE exists.
+ """
+
+ if not settings.TODO_DEFAULT_LIST_SLUG:
+ raise RuntimeError(
+ "This feature requires TODO_DEFAULT_LIST_SLUG: in settings. See documentation."
+ )
+
+ if not TaskList.objects.filter(slug=settings.TODO_DEFAULT_LIST_SLUG).exists():
+ raise RuntimeError(
+ "There is no TaskList with slug specified for TODO_DEFAULT_LIST_SLUG in settings."
+ )
+
+ if request.POST:
+ form = AddExternalTaskForm(request.POST)
+
+ if form.is_valid():
+ current_site = Site.objects.get_current()
+ task = form.save(commit=False)
+ task.task_list = TaskList.objects.get(slug=settings.TODO_DEFAULT_LIST_SLUG)
+ task.created_by = request.user
+ if settings.TODO_DEFAULT_ASSIGNEE:
+ task.assigned_to = User.objects.get(username=settings.TODO_DEFAULT_ASSIGNEE)
+ task.save()
+
+ # Send email to assignee if we have one
+ if task.assigned_to:
+ email_subject = render_to_string(
+ "todo/email/assigned_subject.txt", {"task": task.title}
+ )
+ email_body = render_to_string(
+ "todo/email/assigned_body.txt", {"task": task, "site": current_site}
+ )
+ try:
+ send_mail(
+ email_subject,
+ email_body,
+ task.created_by.email,
+ [task.assigned_to.email],
+ fail_silently=False,
+ )
+ except ConnectionRefusedError:
+ messages.warning(
+ request, "Task saved but mail not sent. Contact your administrator."
+ )
+
+ messages.success(
+ request, "Your trouble ticket has been submitted. We'll get back to you soon."
+ )
+ return redirect(settings.TODO_PUBLIC_SUBMIT_REDIRECT)
+
+ else:
+ form = AddExternalTaskForm(initial={"priority": 999})
+
+ context = {"form": form}
+
+ return render(request, "todo/add_task_external.html", context)
diff --git a/todo/views/list_detail.py b/todo/views/list_detail.py
new file mode 100644
index 0000000..b9e2bba
--- /dev/null
+++ b/todo/views/list_detail.py
@@ -0,0 +1,84 @@
+import bleach
+from django.contrib import messages
+from django.contrib.auth.decorators import login_required
+from django.core.exceptions import PermissionDenied
+from django.http import HttpResponse
+from django.shortcuts import get_object_or_404, redirect, render
+from django.utils import timezone
+
+from todo.forms import AddEditTaskForm
+from todo.models import Task, TaskList
+from todo.utils import send_notify_mail
+
+
+@login_required
+def list_detail(request, list_id=None, list_slug=None, view_completed=False) -> HttpResponse:
+ """Display and manage tasks in a todo list.
+ """
+
+ # Defaults
+ task_list = None
+ form = None
+
+ # Which tasks to show on this list view?
+ if list_slug == "mine":
+ tasks = Task.objects.filter(assigned_to=request.user)
+
+ else:
+ # Show a specific list, ensuring permissions.
+ task_list = get_object_or_404(TaskList, id=list_id)
+ if task_list.group not in request.user.groups.all() and not request.user.is_staff:
+ raise PermissionDenied
+ tasks = Task.objects.filter(task_list=task_list.id)
+
+ # Additional filtering
+ if view_completed:
+ tasks = tasks.filter(completed=True)
+ else:
+ tasks = tasks.filter(completed=False)
+
+ # ######################
+ # Add New Task Form
+ # ######################
+
+ if request.POST.getlist("add_edit_task"):
+ form = AddEditTaskForm(
+ request.user,
+ request.POST,
+ initial={"assigned_to": request.user.id, "priority": 999, "task_list": task_list},
+ )
+
+ if form.is_valid():
+ new_task = form.save(commit=False)
+ new_task.created_date = timezone.now()
+ new_task.note = bleach.clean(form.cleaned_data["note"], strip=True)
+ form.save()
+
+ # Send email alert only if Notify checkbox is checked AND assignee is not same as the submitter
+ if (
+ "notify" in request.POST
+ and new_task.assigned_to
+ and new_task.assigned_to != request.user
+ ):
+ send_notify_mail(new_task)
+
+ messages.success(request, 'New task "{t}" has been added.'.format(t=new_task.title))
+ return redirect(request.path)
+ else:
+ # Don't allow adding new tasks on some views
+ if list_slug not in ["mine", "recent-add", "recent-complete"]:
+ form = AddEditTaskForm(
+ request.user,
+ initial={"assigned_to": request.user.id, "priority": 999, "task_list": task_list},
+ )
+
+ context = {
+ "list_id": list_id,
+ "list_slug": list_slug,
+ "task_list": task_list,
+ "form": form,
+ "tasks": tasks,
+ "view_completed": view_completed,
+ }
+
+ return render(request, "todo/list_detail.html", context)
diff --git a/todo/views/list_lists.py b/todo/views/list_lists.py
new file mode 100644
index 0000000..dda614f
--- /dev/null
+++ b/todo/views/list_lists.py
@@ -0,0 +1,55 @@
+import datetime
+
+from django.contrib import messages
+from django.contrib.auth.decorators import login_required
+from django.http import HttpResponse
+from django.shortcuts import render
+
+from todo.forms import SearchForm
+from todo.models import Task, TaskList
+
+
+@login_required
+def list_lists(request) -> HttpResponse:
+ """Homepage view - list of lists a user can view, and ability to add a list.
+ """
+
+ thedate = datetime.datetime.now()
+ searchform = SearchForm(auto_id=False)
+
+ # Make sure user belongs to at least one group.
+ if request.user.groups.all().count() == 0:
+ messages.warning(
+ request,
+ "You do not yet belong to any groups. Ask your administrator to add you to one.",
+ )
+
+ # Superusers see all lists
+ if request.user.is_superuser:
+ lists = TaskList.objects.all().order_by("group", "name")
+ else:
+ lists = TaskList.objects.filter(group__in=request.user.groups.all()).order_by(
+ "group", "name"
+ )
+
+ list_count = lists.count()
+
+ # superusers see all lists, so count shouldn't filter by just lists the admin belongs to
+ if request.user.is_superuser:
+ task_count = Task.objects.filter(completed=0).count()
+ else:
+ task_count = (
+ Task.objects.filter(completed=0)
+ .filter(task_list__group__in=request.user.groups.all())
+ .count()
+ )
+
+ context = {
+ "lists": lists,
+ "thedate": thedate,
+ "searchform": searchform,
+ "list_count": list_count,
+ "task_count": task_count,
+ }
+
+ return render(request, "todo/list_lists.html", context)
diff --git a/todo/views/reorder_tasks.py b/todo/views/reorder_tasks.py
new file mode 100644
index 0000000..2904f45
--- /dev/null
+++ b/todo/views/reorder_tasks.py
@@ -0,0 +1,28 @@
+from django.contrib.auth.decorators import login_required
+from django.http import HttpResponse
+
+from todo.models import Task
+from django.views.decorators.csrf import csrf_exempt
+
+
+@csrf_exempt
+@login_required
+def reorder_tasks(request) -> HttpResponse:
+ """Handle task re-ordering (priorities) from JQuery drag/drop in list_detail.html
+ """
+ newtasklist = request.POST.getlist("tasktable[]")
+ if newtasklist:
+ # First task in received list is always empty - remove it
+ del newtasklist[0]
+
+ # Re-prioritize each task in list
+ i = 1
+ for id in newtasklist:
+ task = Task.objects.get(pk=id)
+ task.priority = i
+ task.save()
+ i += 1
+
+ # 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)
diff --git a/todo/views/search.py b/todo/views/search.py
new file mode 100644
index 0000000..d321cae
--- /dev/null
+++ b/todo/views/search.py
@@ -0,0 +1,40 @@
+from django.contrib.auth.decorators import login_required
+from django.db.models import Q
+from django.http import HttpResponse
+from django.shortcuts import render
+
+from todo.models import Task
+
+
+@login_required
+def search(request) -> HttpResponse:
+ """Search for tasks user has permission to see.
+ """
+ if request.GET:
+
+ query_string = ""
+ found_tasks = None
+ if ("q" in request.GET) and request.GET["q"].strip():
+ query_string = request.GET["q"]
+
+ found_tasks = Task.objects.filter(
+ Q(title__icontains=query_string) | Q(note__icontains=query_string)
+ )
+ else:
+ # What if they selected the "completed" toggle but didn't enter a query string?
+ # We still need found_tasks in a queryset so it can be "excluded" below.
+ found_tasks = Task.objects.all()
+
+ if "inc_complete" in request.GET:
+ found_tasks = found_tasks.exclude(completed=True)
+
+ else:
+ query_string = None
+ found_tasks = None
+
+ # Only include tasks that are in groups of which this user is a member:
+ if not request.user.is_superuser:
+ found_tasks = found_tasks.filter(task_list__group__in=request.user.groups.all())
+
+ context = {"query_string": query_string, "found_tasks": found_tasks}
+ return render(request, "todo/search_results.html", context)
diff --git a/todo/views/task_detail.py b/todo/views/task_detail.py
new file mode 100644
index 0000000..4f81188
--- /dev/null
+++ b/todo/views/task_detail.py
@@ -0,0 +1,76 @@
+import datetime
+
+import bleach
+from django.contrib import messages
+from django.contrib.auth.decorators import login_required
+from django.core.exceptions import PermissionDenied
+from django.http import HttpResponse
+from django.shortcuts import get_object_or_404, redirect, render
+
+from todo.forms import AddEditTaskForm
+from todo.models import Comment, Task
+from todo.utils import send_email_to_thread_participants, toggle_task_completed
+
+
+@login_required
+def task_detail(request, task_id: int) -> HttpResponse:
+ """View task details. Allow task details to be edited. Process new comments on task.
+ """
+
+ task = get_object_or_404(Task, pk=task_id)
+ comment_list = Comment.objects.filter(task=task_id)
+
+ # Ensure user has permission to view task. Admins can view all tasks.
+ # Get the group this task belongs to, and check whether current user is a member of that group.
+ if task.task_list.group not in request.user.groups.all() and not request.user.is_staff:
+ raise PermissionDenied
+
+ # Save submitted comments
+ if request.POST.get("add_comment"):
+ Comment.objects.create(
+ author=request.user,
+ task=task,
+ body=bleach.clean(request.POST["comment-body"], strip=True),
+ )
+
+ send_email_to_thread_participants(
+ task,
+ request.POST["comment-body"],
+ request.user,
+ subject='New comment posted on task "{}"'.format(task.title),
+ )
+ messages.success(request, "Comment posted. Notification email sent to thread participants.")
+
+ # Save task edits
+ if request.POST.get("add_edit_task"):
+ form = AddEditTaskForm(
+ request.user, request.POST, instance=task, initial={"task_list": task.task_list}
+ )
+
+ if form.is_valid():
+ item = form.save(commit=False)
+ item.note = bleach.clean(form.cleaned_data["note"], strip=True)
+ item.save()
+ messages.success(request, "The task has been edited.")
+ return redirect(
+ "todo:list_detail", list_id=task.task_list.id, list_slug=task.task_list.slug
+ )
+ else:
+ form = AddEditTaskForm(request.user, instance=task, initial={"task_list": task.task_list})
+
+ # Mark complete
+ if request.POST.get("toggle_done"):
+ results_changed = toggle_task_completed(task.id)
+ if results_changed:
+ messages.success(request, f"Changed completion status for task {task.id}")
+
+ return redirect("todo:task_detail", task_id=task.id)
+
+ if task.due_date:
+ thedate = task.due_date
+ else:
+ thedate = datetime.datetime.now()
+
+ context = {"task": task, "comment_list": comment_list, "form": form, "thedate": thedate}
+
+ return render(request, "todo/task_detail.html", context)
diff --git a/todo/views/toggle_done.py b/todo/views/toggle_done.py
new file mode 100644
index 0000000..f80eaaf
--- /dev/null
+++ b/todo/views/toggle_done.py
@@ -0,0 +1,37 @@
+from django.contrib import messages
+from django.contrib.auth.decorators import login_required
+from django.core.exceptions import PermissionDenied
+from django.http import HttpResponse
+from django.shortcuts import get_object_or_404, redirect
+from django.urls import reverse
+
+from todo.models import Task
+from todo.utils import toggle_task_completed
+
+
+@login_required
+def toggle_done(request, task_id: int) -> HttpResponse:
+ """Toggle the completed status of a task from done to undone, or vice versa.
+ Redirect to the list from which the task came.
+ """
+
+ task = get_object_or_404(Task, pk=task_id)
+
+ # Permissions
+ if not (
+ (request.user.is_superuser)
+ or (task.created_by == request.user)
+ or (task.assigned_to == request.user)
+ or (task.task_list.group in request.user.groups.all())
+ ):
+ raise PermissionDenied
+
+ toggle_task_completed(task.id)
+ messages.success(request, "Task status changed for '{}'".format(task.title))
+
+ return redirect(
+ reverse(
+ "todo:list_detail",
+ kwargs={"list_id": task.task_list.id, "list_slug": task.task_list.slug},
+ )
+ )
From 69530852853125effbd677b71cdce64891752634 Mon Sep 17 00:00:00 2001
From: Scot Hacker
Date: Tue, 8 Jan 2019 23:09:49 -0800
Subject: [PATCH 087/210] Correctly specify minimum requirement of Python 3.6
---
README.md | 4 +++-
index.md | 18 +++++++++---------
todo/__init__.py | 2 +-
3 files changed, 13 insertions(+), 11 deletions(-)
diff --git a/README.md b/README.md
index ffecec2..0101a92 100644
--- a/README.md
+++ b/README.md
@@ -19,7 +19,7 @@ assignment application for Django, designed to be dropped into an existing site
## Requirements
* Django 2.0+
-* Python 3.3+
+* Python 3.6+
* jQuery (full version, not "slim", for drag/drop prioritization)
* Bootstrap (to work with provided templates, though you can override them)
* bleach (`pip install bleach`)
@@ -167,6 +167,8 @@ The previous `tox` system was removed with the v2 release, since we no longer ai
# Version History
+**2.1.1** Correct Python version requirement in documentation to Python 3.6
+
**2.1.1** Split up views into separate modules.
**2.1.0** December 2018: No longer allowing Javascript in task or comment bodies. Misc bug fixes.
diff --git a/index.md b/index.md
index 3cfdc83..4242d91 100644
--- a/index.md
+++ b/index.md
@@ -1,4 +1,4 @@
-# django-todo
+# django-todo
django-todo is a pluggable, multi-user, multi-group task management and
assignment application for Django, designed to be dropped into an existing site as a reusable app. django-todo can be used as a personal to-do tracker, or a group task management system, or a ticketing system for organizations (or all of these at once!)
@@ -19,7 +19,7 @@ assignment application for Django, designed to be dropped into an existing site
## Requirements
* Django 2.0+
-* Python 3.3+
+* Python 3.6+
* jQuery (full version, not "slim", for drag/drop prioritization)
* Bootstrap (to work with provided templates, though you can override them)
@@ -27,13 +27,13 @@ assignment application for Django, designed to be dropped into an existing site
The assumption is that your organization/publication/company has multiple groups of employees, each with multiple users (where actual users and groups map to Django Users and Groups). Users may belong to multiple groups, and each group can have multiple todo lists.
-You must have at least one Group set up in Django admin, and that group must have at least one User as a member. This is true even if you're the sole user of django-todo.
+You must have at least one Group set up in Django admin, and that group must have at least one User as a member. This is true even if you're the sole user of django-todo.
-Users can view and modify all to-do lists belonging to their group(s). Only users with `is_staff()` can add or delete lists.
+Users can view and modify all to-do lists belonging to their group(s). Only users with `is_staff()` can add or delete lists.
Identical list names can exist in different groups, but not in the same group.
-Emails are generated to the assigned-to person when new tasks are created.
+Emails are generated to the assigned-to person when new tasks are created.
Comment threads can be added to tasks. Each participant in a thread receives email when new comments are added.
@@ -45,7 +45,7 @@ django-todo v2 makes use of features only available in Django 2.0. It will not w
# Installation
-django-todo is a Django app, not a project site. It needs a site to live in. You can either install it into an existing Django project site, or clone the django-todo [demo site (GTD)](https://github.com/shacker/gtd).
+django-todo is a Django app, not a project site. It needs a site to live in. You can either install it into an existing Django project site, or clone the django-todo [demo site (GTD)](https://github.com/shacker/gtd).
If using your own site, be sure you have jQuery and Bootstrap wired up and working.
@@ -63,7 +63,7 @@ django-todo comes with its own `todo/base.html`, which extends your master `base
If you use some other name for your main content area, you'll need to override and alter the provided templates.
-All views are login-required. Therefore, you must have a working user authentication system.
+All views are login-required. Therefore, you must have a working user authentication system.
For email notifications to work, make sure your site/project is [set up to send email](https://docs.djangoproject.com/en/2.0/topics/email/).
@@ -79,7 +79,7 @@ Add to your settings:
INSTALLED_APPS = (
...
'todo',
- )
+ )
Create database tables:
@@ -98,7 +98,7 @@ django-todo makes use of the Django `messages` system. Make sure you have someth
Log in and access `/todo`!
-The provided templates are fairly bare-bones, and are meant as starting points only. Unlike previous versions of django-todo, they now ship as Bootstrap examples, but feel free to override them - there is no hard dependency on Bootstrap. To override a template, create a `todo` folder in your project's `templates` dir, then copy the template you want to override from django-todo source and into that dir.
+The provided templates are fairly bare-bones, and are meant as starting points only. Unlike previous versions of django-todo, they now ship as Bootstrap examples, but feel free to override them - there is no hard dependency on Bootstrap. To override a template, create a `todo` folder in your project's `templates` dir, then copy the template you want to override from django-todo source and into that dir.
If you wish to use the public ticket-filing system, first create the list into which those tickets should be filed, then add its slug to `TODO_DEFAULT_LIST_SLUG` in settings (more on settings below).
diff --git a/todo/__init__.py b/todo/__init__.py
index ae92868..d682689 100644
--- a/todo/__init__.py
+++ b/todo/__init__.py
@@ -1,7 +1,7 @@
"""
A multi-user, multi-group task management and assignment system for Django.
"""
-__version__ = '2.1.1'
+__version__ = '2.1.2'
__author__ = 'Scot Hacker'
__email__ = 'shacker@birdhouse.org'
From 91b9a099a30274ce8bae6d705fcd0a5150159739 Mon Sep 17 00:00:00 2001
From: Scot Hacker
Date: Thu, 10 Jan 2019 00:39:21 -0800
Subject: [PATCH 088/210] Enforce and test TODO_STAFF_ONLY setting
---
README.md | 9 ++++++---
todo/__init__.py | 2 +-
todo/tests/test_views.py | 30 +++++++++++++++++++++---------
todo/utils.py | 24 ++++++++++--------------
todo/views/add_list.py | 12 +++++++++---
todo/views/del_list.py | 13 +++++--------
todo/views/delete_task.py | 5 ++++-
todo/views/external_add.py | 4 +++-
todo/views/list_detail.py | 5 +++--
todo/views/list_lists.py | 4 +++-
todo/views/reorder_tasks.py | 6 ++++--
todo/views/search.py | 9 ++++++---
todo/views/task_detail.py | 5 +++--
todo/views/toggle_done.py | 4 +++-
14 files changed, 81 insertions(+), 51 deletions(-)
diff --git a/README.md b/README.md
index 0101a92..65ede23 100644
--- a/README.md
+++ b/README.md
@@ -30,7 +30,7 @@ The assumption is that your organization/publication/company has multiple groups
You must have at least one Group set up in Django admin, and that group must have at least one User as a member. This is true even if you're the sole user of django-todo.
-Users can view and modify all to-do lists belonging to their group(s). Only users with `is_staff()` can add or delete lists.
+Users can view and modify all to-do lists belonging to their group(s). Only users with `is_staff` can add or delete lists.
Identical list names can exist in different groups, but not in the same group.
@@ -108,8 +108,9 @@ If you wish to use the public ticket-filing system, first create the list into w
Optional configuration options:
```
-# Restrict access to todo lists/views to `is_staff()` users.
-# False here falls back to `is_authenticated()` users.
+# Restrict access to ALL todo lists/views to `is_staff` users.
+# If False or unset, all users can see all views (but more granular permissions are still enforced
+# within views, such as requiring staff for adding and deleting lists).
TODO_STAFF_ONLY = True
# If you use the "public" ticket filing option, to whom should these tickets be assigned?
@@ -167,6 +168,8 @@ The previous `tox` system was removed with the v2 release, since we no longer ai
# Version History
+**2.2.0** Re-instate enforcement of TODO_STAFF_ONLY setting
+
**2.1.1** Correct Python version requirement in documentation to Python 3.6
**2.1.1** Split up views into separate modules.
diff --git a/todo/__init__.py b/todo/__init__.py
index d682689..33eaf46 100644
--- a/todo/__init__.py
+++ b/todo/__init__.py
@@ -1,7 +1,7 @@
"""
A multi-user, multi-group task management and assignment system for Django.
"""
-__version__ = '2.1.2'
+__version__ = '2.2.0'
__author__ = 'Scot Hacker'
__email__ = 'shacker@birdhouse.org'
diff --git a/todo/tests/test_views.py b/todo/tests/test_views.py
index dbfa1d0..72f2052 100644
--- a/todo/tests/test_views.py
+++ b/todo/tests/test_views.py
@@ -144,18 +144,11 @@ def test_no_javascript_in_comments(todo_setup, client):
# ### PERMISSIONS ###
-"""
-Some views are for staff users only.
-We've already smoke-tested with Admin user - try these with normal user.
-These exercise our custom @staff_only decorator without calling that function explicitly.
-"""
-
-
def test_view_add_list_nonadmin(todo_setup, client):
url = reverse("todo:add_list")
client.login(username="you", password="password")
response = client.get(url)
- assert response.status_code == 403
+ assert response.status_code == 302 # Redirected to login
def test_view_del_list_nonadmin(todo_setup, client):
@@ -163,7 +156,7 @@ def test_view_del_list_nonadmin(todo_setup, client):
url = reverse("todo:del_list", kwargs={"list_id": tlist.id, "list_slug": tlist.slug})
client.login(username="you", password="password")
response = client.get(url)
- assert response.status_code == 403
+ assert response.status_code == 302 # Fedirected to login
def test_view_list_mine(todo_setup, client):
@@ -220,3 +213,22 @@ def test_view_task_not_in_my_group(todo_setup, client):
response = client.get(url)
assert response.status_code == 403
+
+def test_setting_TODO_STAFF_ONLY_False(todo_setup, client, settings):
+ # We use Django's user_passes_test to call `staff_check` utility function on all views.
+ # Just testing one view here; if it works, it works for all of them.
+ settings.TODO_STAFF_ONLY = False
+ url = reverse("todo:lists")
+ client.login(username="u2", password="password")
+ response = client.get(url)
+ assert response.status_code == 200
+
+
+def test_setting_TODO_STAFF_ONLY_True(todo_setup, client, settings):
+ # We use Django's user_passes_test to call `staff_check` utility function on all views.
+ # Just testing one view here; if it works, it works for all of them.
+ settings.TODO_STAFF_ONLY = True
+ url = reverse("todo:lists")
+ client.login(username="u2", password="password")
+ response = client.get(url)
+ assert response.status_code == 302 # Redirected to login view
diff --git a/todo/utils.py b/todo/utils.py
index 6e85b06..dca0a11 100644
--- a/todo/utils.py
+++ b/todo/utils.py
@@ -1,26 +1,22 @@
+from django.conf import settings
from django.contrib.sites.models import Site
-from django.core.exceptions import PermissionDenied
from django.core.mail import send_mail
from django.template.loader import render_to_string
from todo.models import Comment, Task
-def staff_only(function):
- """
- Custom view decorator allows us to raise 403 on insufficient permissions,
- rather than redirect user to login view.
+def staff_check(user):
+ """If TODO_STAFF_ONLY is set to True, limit view access to staff users only.
+ # FIXME: More granular access control is needed... but need to do it generically,
+ # to satisfy all possible todo implementations.
"""
- def wrap(request, *args, **kwargs):
- if request.user.is_staff:
- return function(request, *args, **kwargs)
- else:
- raise PermissionDenied
-
- wrap.__doc__ = function.__doc__
- wrap.__name__ = function.__name__
- return wrap
+ if hasattr(settings, "TODO_STAFF_ONLY") and settings.TODO_STAFF_ONLY:
+ return user.is_staff
+ else:
+ # If unset or False, allow all logged in users
+ return True
def send_notify_mail(new_task):
diff --git a/todo/views/add_list.py b/todo/views/add_list.py
index e8a70ae..8cf9f03 100644
--- a/todo/views/add_list.py
+++ b/todo/views/add_list.py
@@ -1,20 +1,25 @@
from django.contrib import messages
-from django.contrib.auth.decorators import login_required
+from django.contrib.auth.decorators import login_required, user_passes_test
+from django.core.exceptions import PermissionDenied
from django.db import IntegrityError
from django.http import HttpResponse
from django.shortcuts import redirect, render
from django.utils.text import slugify
from todo.forms import AddTaskListForm
-from todo.utils import staff_only
+from todo.utils import staff_check
-@staff_only
@login_required
+@user_passes_test(staff_check)
def add_list(request) -> HttpResponse:
"""Allow users to add a new todo list to the group they're in.
"""
+ # Only staffers can add lists.
+ if not request.user.is_staff:
+ raise PermissionDenied
+
if request.POST:
form = AddTaskListForm(request.user, request.POST)
if form.is_valid():
@@ -33,6 +38,7 @@ def add_list(request) -> HttpResponse:
)
else:
if request.user.groups.all().count() == 1:
+ # FIXME: Assuming first of user's groups here; better to prompt for group
form = AddTaskListForm(request.user, initial={"group": request.user.groups.all()[0]})
else:
form = AddTaskListForm(request.user)
diff --git a/todo/views/del_list.py b/todo/views/del_list.py
index d2a9164..5ee1cd0 100644
--- a/todo/views/del_list.py
+++ b/todo/views/del_list.py
@@ -1,25 +1,22 @@
-import datetime
-
from django.contrib import messages
-from django.contrib.auth.decorators import login_required
+from django.contrib.auth.decorators import login_required, user_passes_test
from django.http import HttpResponse
from django.shortcuts import render, redirect, get_object_or_404
from django.core.exceptions import PermissionDenied
from todo.models import Task, TaskList
-from todo.utils import staff_only
+from todo.utils import staff_check
-@staff_only
@login_required
+@user_passes_test(staff_check)
def del_list(request, list_id: int, list_slug: str) -> HttpResponse:
"""Delete an entire list. Only staff members should be allowed to access this view.
"""
task_list = get_object_or_404(TaskList, id=list_id)
- # Ensure user has permission to delete list. Admins can delete all lists.
- # Get the group this list belongs to, and check whether current user is a member of that group.
- # FIXME: This means any group member can delete lists, which is probably too permissive.
+ # Ensure user has permission to delete list. Get the group this list belongs to,
+ # and check whether current user is a member of that group AND a staffer.
if task_list.group not in request.user.groups.all() and not request.user.is_staff:
raise PermissionDenied
diff --git a/todo/views/delete_task.py b/todo/views/delete_task.py
index a7c1334..9e3ec99 100644
--- a/todo/views/delete_task.py
+++ b/todo/views/delete_task.py
@@ -1,13 +1,16 @@
from django.contrib import messages
-from django.contrib.auth.decorators import login_required
+from django.contrib.auth.decorators import login_required, user_passes_test
from django.core.exceptions import PermissionDenied
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from todo.models import Task
+from todo.utils import staff_check
+
@login_required
+@user_passes_test(staff_check)
def delete_task(request, task_id: int) -> HttpResponse:
"""Delete specified task.
Redirect to the list from which the task came.
diff --git a/todo/views/external_add.py b/todo/views/external_add.py
index c8fdac1..2638996 100644
--- a/todo/views/external_add.py
+++ b/todo/views/external_add.py
@@ -1,6 +1,6 @@
from django.conf import settings
from django.contrib import messages
-from django.contrib.auth.decorators import login_required
+from django.contrib.auth.decorators import login_required, user_passes_test
from django.contrib.auth.models import User
from django.contrib.sites.models import Site
from django.core.mail import send_mail
@@ -10,9 +10,11 @@ from django.template.loader import render_to_string
from todo.forms import AddExternalTaskForm
from todo.models import TaskList
+from todo.utils import staff_check
@login_required
+@user_passes_test(staff_check)
def external_add(request) -> HttpResponse:
"""Allow authenticated users who don't have access to the rest of the ticket system to file a ticket
in the list specified in settings (e.g. django-todo can be used a ticket filing system for a school, where
diff --git a/todo/views/list_detail.py b/todo/views/list_detail.py
index b9e2bba..32bb9b6 100644
--- a/todo/views/list_detail.py
+++ b/todo/views/list_detail.py
@@ -1,6 +1,6 @@
import bleach
from django.contrib import messages
-from django.contrib.auth.decorators import login_required
+from django.contrib.auth.decorators import login_required, user_passes_test
from django.core.exceptions import PermissionDenied
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
@@ -8,10 +8,11 @@ from django.utils import timezone
from todo.forms import AddEditTaskForm
from todo.models import Task, TaskList
-from todo.utils import send_notify_mail
+from todo.utils import send_notify_mail, staff_check
@login_required
+@user_passes_test(staff_check)
def list_detail(request, list_id=None, list_slug=None, view_completed=False) -> HttpResponse:
"""Display and manage tasks in a todo list.
"""
diff --git a/todo/views/list_lists.py b/todo/views/list_lists.py
index dda614f..7672eca 100644
--- a/todo/views/list_lists.py
+++ b/todo/views/list_lists.py
@@ -1,15 +1,17 @@
import datetime
from django.contrib import messages
-from django.contrib.auth.decorators import login_required
+from django.contrib.auth.decorators import login_required, user_passes_test
from django.http import HttpResponse
from django.shortcuts import render
from todo.forms import SearchForm
from todo.models import Task, TaskList
+from todo.utils import staff_check
@login_required
+@user_passes_test(staff_check)
def list_lists(request) -> HttpResponse:
"""Homepage view - list of lists a user can view, and ability to add a list.
"""
diff --git a/todo/views/reorder_tasks.py b/todo/views/reorder_tasks.py
index 2904f45..843a086 100644
--- a/todo/views/reorder_tasks.py
+++ b/todo/views/reorder_tasks.py
@@ -1,12 +1,14 @@
-from django.contrib.auth.decorators import login_required
+from django.contrib.auth.decorators import login_required, user_passes_test
from django.http import HttpResponse
+from django.views.decorators.csrf import csrf_exempt
from todo.models import Task
-from django.views.decorators.csrf import csrf_exempt
+from todo.utils import staff_check
@csrf_exempt
@login_required
+@user_passes_test(staff_check)
def reorder_tasks(request) -> HttpResponse:
"""Handle task re-ordering (priorities) from JQuery drag/drop in list_detail.html
"""
diff --git a/todo/views/search.py b/todo/views/search.py
index d321cae..b7f5e17 100644
--- a/todo/views/search.py
+++ b/todo/views/search.py
@@ -1,18 +1,22 @@
-from django.contrib.auth.decorators import login_required
+from django.contrib.auth.decorators import login_required, user_passes_test
from django.db.models import Q
from django.http import HttpResponse
from django.shortcuts import render
from todo.models import Task
+from todo.utils import staff_check
@login_required
+@user_passes_test(staff_check)
def search(request) -> HttpResponse:
"""Search for tasks user has permission to see.
"""
+
+ query_string = ""
+
if request.GET:
- query_string = ""
found_tasks = None
if ("q" in request.GET) and request.GET["q"].strip():
query_string = request.GET["q"]
@@ -29,7 +33,6 @@ def search(request) -> HttpResponse:
found_tasks = found_tasks.exclude(completed=True)
else:
- query_string = None
found_tasks = None
# Only include tasks that are in groups of which this user is a member:
diff --git a/todo/views/task_detail.py b/todo/views/task_detail.py
index 4f81188..95432db 100644
--- a/todo/views/task_detail.py
+++ b/todo/views/task_detail.py
@@ -2,17 +2,18 @@ import datetime
import bleach
from django.contrib import messages
-from django.contrib.auth.decorators import login_required
+from django.contrib.auth.decorators import login_required, user_passes_test
from django.core.exceptions import PermissionDenied
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from todo.forms import AddEditTaskForm
from todo.models import Comment, Task
-from todo.utils import send_email_to_thread_participants, toggle_task_completed
+from todo.utils import send_email_to_thread_participants, toggle_task_completed, staff_check
@login_required
+@user_passes_test(staff_check)
def task_detail(request, task_id: int) -> HttpResponse:
"""View task details. Allow task details to be edited. Process new comments on task.
"""
diff --git a/todo/views/toggle_done.py b/todo/views/toggle_done.py
index f80eaaf..6a3934e 100644
--- a/todo/views/toggle_done.py
+++ b/todo/views/toggle_done.py
@@ -1,5 +1,5 @@
from django.contrib import messages
-from django.contrib.auth.decorators import login_required
+from django.contrib.auth.decorators import login_required, user_passes_test
from django.core.exceptions import PermissionDenied
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect
@@ -7,9 +7,11 @@ from django.urls import reverse
from todo.models import Task
from todo.utils import toggle_task_completed
+from todo.utils import staff_check
@login_required
+@user_passes_test(staff_check)
def toggle_done(request, task_id: int) -> HttpResponse:
"""Toggle the completed status of a task from done to undone, or vice versa.
Redirect to the list from which the task came.
From 513ef59d4aa1bb2d8af75654a091de20ae5ed46c Mon Sep 17 00:00:00 2001
From: Scot Hacker
Date: Thu, 10 Jan 2019 00:54:14 -0800
Subject: [PATCH 089/210] Add comment
---
todo/views/add_list.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/todo/views/add_list.py b/todo/views/add_list.py
index 8cf9f03..8304852 100644
--- a/todo/views/add_list.py
+++ b/todo/views/add_list.py
@@ -16,7 +16,7 @@ def add_list(request) -> HttpResponse:
"""Allow users to add a new todo list to the group they're in.
"""
- # Only staffers can add lists.
+ # Only staffers can add lists, regardless of TODO_STAFF_USER setting.
if not request.user.is_staff:
raise PermissionDenied
From dc3d4b647e0ab87409522709d78d3d6059bf1ee4 Mon Sep 17 00:00:00 2001
From: Scot Hacker
Date: Thu, 10 Jan 2019 00:55:48 -0800
Subject: [PATCH 090/210] Update index.me for readthedocs
---
index.md | 20 ++++++++++++++------
1 file changed, 14 insertions(+), 6 deletions(-)
diff --git a/index.md b/index.md
index 4242d91..cc94810 100644
--- a/index.md
+++ b/index.md
@@ -22,6 +22,7 @@ assignment application for Django, designed to be dropped into an existing site
* Python 3.6+
* jQuery (full version, not "slim", for drag/drop prioritization)
* Bootstrap (to work with provided templates, though you can override them)
+* bleach (`pip install bleach`)
## Overview
@@ -29,7 +30,7 @@ The assumption is that your organization/publication/company has multiple groups
You must have at least one Group set up in Django admin, and that group must have at least one User as a member. This is true even if you're the sole user of django-todo.
-Users can view and modify all to-do lists belonging to their group(s). Only users with `is_staff()` can add or delete lists.
+Users can view and modify all to-do lists belonging to their group(s). Only users with `is_staff` can add or delete lists.
Identical list names can exist in different groups, but not in the same group.
@@ -94,7 +95,7 @@ Add links to your site's navigation system:
Todo ListsMy Tasks
-django-todo makes use of the Django `messages` system. Make sure you have something like [this](https://docs.djangoproject.com/en/2.0/ref/contrib/messages/#displaying-messages) in your `base.html`.
+django-todo makes use of the Django `messages` system. Make sure you have something like [this](https://docs.djangoproject.com/en/2.0/ref/contrib/messages/#displaying-messages) (link) in your `base.html`.
Log in and access `/todo`!
@@ -107,8 +108,9 @@ If you wish to use the public ticket-filing system, first create the list into w
Optional configuration options:
```
-# Restrict access to todo lists/views to `is_staff()` users.
-# False here falls back to `is_authenticated()` users.
+# Restrict access to ALL todo lists/views to `is_staff` users.
+# If False or unset, all users can see all views (but more granular permissions are still enforced
+# within views, such as requiring staff for adding and deleting lists).
TODO_STAFF_ONLY = True
# If you use the "public" ticket filing option, to whom should these tickets be assigned?
@@ -166,6 +168,14 @@ The previous `tox` system was removed with the v2 release, since we no longer ai
# Version History
+**2.2.0** Re-instate enforcement of TODO_STAFF_ONLY setting
+
+**2.1.1** Correct Python version requirement in documentation to Python 3.6
+
+**2.1.1** Split up views into separate modules.
+
+**2.1.0** December 2018: No longer allowing Javascript in task or comment bodies. Misc bug fixes.
+
**2.0.3** April 2018: Bump production status in setup.py
**2.0.2** April 2018: Improve notification email subjects and bodies
@@ -215,5 +225,3 @@ ALL groups, not just the groups they "belong" to)
**0.9.1** - Removed context_processors.py - leftover turdlet
**0.9** - First release
-
-
From 8e52aad828320be1eb0452a88ebcfac2c1f870b8 Mon Sep 17 00:00:00 2001
From: Scot Hacker
Date: Thu, 10 Jan 2019 23:18:09 -0800
Subject: [PATCH 091/210] Move index.md to docs dir
---
index.md => docs/index.md | 0
todo/views/del_list.py | 4 ++--
2 files changed, 2 insertions(+), 2 deletions(-)
rename index.md => docs/index.md (100%)
diff --git a/index.md b/docs/index.md
similarity index 100%
rename from index.md
rename to docs/index.md
diff --git a/todo/views/del_list.py b/todo/views/del_list.py
index 5ee1cd0..c4e044f 100644
--- a/todo/views/del_list.py
+++ b/todo/views/del_list.py
@@ -1,8 +1,8 @@
from django.contrib import messages
from django.contrib.auth.decorators import login_required, user_passes_test
-from django.http import HttpResponse
-from django.shortcuts import render, redirect, get_object_or_404
from django.core.exceptions import PermissionDenied
+from django.http import HttpResponse
+from django.shortcuts import get_object_or_404, redirect, render
from todo.models import Task, TaskList
from todo.utils import staff_check
From fdd14392febdfff6add346e33759999fe8b0990b Mon Sep 17 00:00:00 2001
From: Scot Hacker
Date: Thu, 10 Jan 2019 23:37:04 -0800
Subject: [PATCH 092/210] Add conf.py for readthedocs
---
docs/conf.py | 1 +
1 file changed, 1 insertion(+)
create mode 100644 docs/conf.py
diff --git a/docs/conf.py b/docs/conf.py
new file mode 100644
index 0000000..5b791f8
--- /dev/null
+++ b/docs/conf.py
@@ -0,0 +1 @@
+html_theme = "classic"
From 7b58a12d979de78be7dbfc962d25af6c5ff40d40 Mon Sep 17 00:00:00 2001
From: Scot Hacker
Date: Thu, 10 Jan 2019 23:39:52 -0800
Subject: [PATCH 093/210] Change RTD theme
---
docs/conf.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/conf.py b/docs/conf.py
index 5b791f8..52843a3 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -1 +1 @@
-html_theme = "classic"
+html_theme = "Windmill"
From 3816abd123b99ee338118d074aac9779f96dfe58 Mon Sep 17 00:00:00 2001
From: Scot Hacker
Date: Thu, 10 Jan 2019 23:42:48 -0800
Subject: [PATCH 094/210] Tweak RTD theme invocation
---
docs/conf.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/conf.py b/docs/conf.py
index 52843a3..a81ffea 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -1 +1 @@
-html_theme = "Windmill"
+theme = "Windmill"
From 0abc9bf16c49d3d8b33f34e11d23e1838a337d2c Mon Sep 17 00:00:00 2001
From: Scot Hacker
Date: Thu, 10 Jan 2019 23:46:24 -0800
Subject: [PATCH 095/210] Move conf.py to mkdocs.yml
---
docs/conf.py | 1 -
mkdocs.yml | 2 ++
2 files changed, 2 insertions(+), 1 deletion(-)
delete mode 100644 docs/conf.py
create mode 100644 mkdocs.yml
diff --git a/docs/conf.py b/docs/conf.py
deleted file mode 100644
index a81ffea..0000000
--- a/docs/conf.py
+++ /dev/null
@@ -1 +0,0 @@
-theme = "Windmill"
diff --git a/mkdocs.yml b/mkdocs.yml
new file mode 100644
index 0000000..79a20c1
--- /dev/null
+++ b/mkdocs.yml
@@ -0,0 +1,2 @@
+site_name: "django-todo"
+theme: "Windmill"
From 891148e4961d0a47760563fe5edb1877da9ca614 Mon Sep 17 00:00:00 2001
From: Scot Hacker
Date: Thu, 10 Jan 2019 23:48:23 -0800
Subject: [PATCH 096/210] Supported RTD theme
---
mkdocs.yml | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/mkdocs.yml b/mkdocs.yml
index 79a20c1..cbe1ad4 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -1,2 +1,2 @@
-site_name: "django-todo"
-theme: "Windmill"
+site_name: "readthedocs"
+theme: "readthedocs"
From 01cab7a82fec505b1d3b39eea67d2da66ab011be Mon Sep 17 00:00:00 2001
From: Scot Hacker
Date: Sun, 10 Feb 2019 11:06:36 -0800
Subject: [PATCH 097/210] Convert task_delete and task_done views from GET to
POST
---
README.md | 2 ++
todo/__init__.py | 2 +-
todo/templates/todo/list_detail.html | 9 ++++--
todo/templates/todo/task_detail.html | 45 ++++++++++++++++++++--------
todo/tests/test_views.py | 28 +++++++++++++++--
todo/views/delete_task.py | 37 +++++++++++++----------
todo/views/list_lists.py | 2 +-
todo/views/toggle_done.py | 36 ++++++++++++----------
8 files changed, 109 insertions(+), 52 deletions(-)
diff --git a/README.md b/README.md
index 65ede23..5c15123 100644
--- a/README.md
+++ b/README.md
@@ -168,6 +168,8 @@ The previous `tox` system was removed with the v2 release, since we no longer ai
# Version History
+**2.2.1** Convert task delete and toggle_done views to POST only
+
**2.2.0** Re-instate enforcement of TODO_STAFF_ONLY setting
**2.1.1** Correct Python version requirement in documentation to Python 3.6
diff --git a/todo/__init__.py b/todo/__init__.py
index 33eaf46..cfae594 100644
--- a/todo/__init__.py
+++ b/todo/__init__.py
@@ -1,7 +1,7 @@
"""
A multi-user, multi-group task management and assignment system for Django.
"""
-__version__ = '2.2.0'
+__version__ = '2.2.1'
__author__ = 'Scot Hacker'
__email__ = 'shacker@birdhouse.org'
diff --git a/todo/templates/todo/list_detail.html b/todo/templates/todo/list_detail.html
index 3edc7ad..c699d1c 100644
--- a/todo/templates/todo/list_detail.html
+++ b/todo/templates/todo/list_detail.html
@@ -52,14 +52,17 @@
{% if task.assigned_to %}{{ task.assigned_to }}{% else %}Anyone{% endif %}
From b3d94ab6085f0830b1283e856aadead976a44cf5 Mon Sep 17 00:00:00 2001
From: Scot Hacker
Date: Mon, 25 Mar 2019 23:43:34 -0700
Subject: [PATCH 109/210] Update readme for 2.3 release
---
README.md | 31 +++---
docs/index.md | 266 +++++++++++++++++++++++++++++++++++++-------------
2 files changed, 214 insertions(+), 83 deletions(-)
diff --git a/README.md b/README.md
index eb158c4..8860e01 100644
--- a/README.md
+++ b/README.md
@@ -15,6 +15,7 @@ assignment application for Django, designed to be dropped into an existing site
* Mobile-friendly (work in progress)
* Separate view for My Tasks (across lists)
* Batch-import tasks via CSV
+* Integrated mail tracking (unify a task list with an email box)
## Requirements
@@ -28,7 +29,7 @@ assignment application for Django, designed to be dropped into an existing site
## Overview
-The assumption is that your organization/publication/company has multiple groups of employees, each with multiple users (where actual users and groups map to Django Users and Groups). Users may belong to multiple groups, and each group can have multiple todo lists.
+We assume that your organization has multiple groups of employees, each with multiple users (where actual users and groups map to Django Users and Groups). Users may belong to multiple groups, and each group can have multiple todo lists.
You must have at least one Group set up in Django admin, and that group must have at least one User as a member. This is true even if you're the sole user of django-todo.
@@ -78,23 +79,27 @@ Put django-todo/todo somewhere on your Python path, or install via pip:
Add to your settings:
- INSTALLED_APPS = (
- ...
- 'todo',
- )
+```
+INSTALLED_APPS = (
+ ...
+ 'todo',
+)
+```
-Create database tables:
+Migrate in database tables:
- python manage.py migrate todo
+`python manage.py migrate todo`
Add to your URL conf:
- path('todo/', include('todo.urls', namespace="todo")),
+`path('todo/', include('todo.urls', namespace="todo")),`
Add links to your site's navigation system:
- Todo Lists
- My Tasks
+```
+Todo Lists
+My Tasks
+```
django-todo makes use of the Django `messages` system. Make sure you have something like [this](https://docs.djangoproject.com/en/2.0/ref/contrib/messages/#displaying-messages) (link) in your `base.html`.
@@ -155,7 +160,7 @@ django-todo has the ability to batch-import ("upsert") tasks from a specifically
**Web Importer**
-Link from your navigation to `{url "todo:import_csv"}`
+Link from your navigation to `{url "todo:import_csv"}`. Follow the resulting link for the CSV web upload view.
### CSV Formatting
@@ -307,9 +312,7 @@ django-todo no longer references a jQuery datepicker, but defaults to native htm
## Version History
-**2.4.0** Added ability to batch-import tasks via CSV
-
-**2.3.0** Implement mail tracking system
+**2.3.0** Implement mail tracking system. Added ability to batch-import tasks via CSV. Fixed task re-ordering if task deleted behind the scenes.
**2.2.2** Update dependencies
diff --git a/docs/index.md b/docs/index.md
index f60533e..8860e01 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -15,6 +15,7 @@ assignment application for Django, designed to be dropped into an existing site
* Mobile-friendly (work in progress)
* Separate view for My Tasks (across lists)
* Batch-import tasks via CSV
+* Integrated mail tracking (unify a task list with an email box)
## Requirements
@@ -24,10 +25,11 @@ assignment application for Django, designed to be dropped into an existing site
* jQuery (full version, not "slim", for drag/drop prioritization)
* Bootstrap (to work with provided templates, though you can override them)
* bleach (`pip install bleach`)
+* django-autocomplete-light (optional, required for task merging)
## Overview
-The assumption is that your organization/publication/company has multiple groups of employees, each with multiple users (where actual users and groups map to Django Users and Groups). Users may belong to multiple groups, and each group can have multiple todo lists.
+We assume that your organization has multiple groups of employees, each with multiple users (where actual users and groups map to Django Users and Groups). Users may belong to multiple groups, and each group can have multiple todo lists.
You must have at least one Group set up in Django admin, and that group must have at least one User as a member. This is true even if you're the sole user of django-todo.
@@ -51,10 +53,9 @@ django-todo is a Django app, not a project site. It needs a site to live in. You
If using your own site, be sure you have jQuery and Bootstrap wired up and working.
-django-todo pages that require it will insert additional CSS/JavaScript into page heads,
-so your project's base templates must include:
+django-todo views that require it will insert additional CSS/JavaScript into page heads, so your project's base templates must include:
-```
+```jinja
{% block extrahead %}{% endblock extrahead %}
{% block extra_js %}{% endblock extra_js %}
```
@@ -78,37 +79,45 @@ Put django-todo/todo somewhere on your Python path, or install via pip:
Add to your settings:
- INSTALLED_APPS = (
- ...
- 'todo',
- )
+```
+INSTALLED_APPS = (
+ ...
+ 'todo',
+)
+```
-Create database tables:
+Migrate in database tables:
- python manage.py migrate todo
+`python manage.py migrate todo`
Add to your URL conf:
- path('todo/', include('todo.urls', namespace="todo")),
+`path('todo/', include('todo.urls', namespace="todo")),`
Add links to your site's navigation system:
- Todo Lists
- My Tasks
+```
+Todo Lists
+My Tasks
+```
django-todo makes use of the Django `messages` system. Make sure you have something like [this](https://docs.djangoproject.com/en/2.0/ref/contrib/messages/#displaying-messages) (link) in your `base.html`.
Log in and access `/todo`!
+### Customizing Templates
+
The provided templates are fairly bare-bones, and are meant as starting points only. Unlike previous versions of django-todo, they now ship as Bootstrap examples, but feel free to override them - there is no hard dependency on Bootstrap. To override a template, create a `todo` folder in your project's `templates` dir, then copy the template you want to override from django-todo source and into that dir.
+### Filing Public Tickets
+
If you wish to use the public ticket-filing system, first create the list into which those tickets should be filed, then add its slug to `TODO_DEFAULT_LIST_SLUG` in settings (more on settings below).
## Settings
-Optional configuration options:
+Optional configuration params, which can be added to your project settings:
-```
+```python
# Restrict access to ALL todo lists/views to `is_staff` users.
# If False or unset, all users can see all views (but more granular permissions are still enforced
# within views, such as requiring staff for adding and deleting lists).
@@ -127,12 +136,162 @@ TODO_DEFAULT_LIST_SLUG = 'tickets'
# Defaults to "/"
TODO_PUBLIC_SUBMIT_REDIRECT = 'dashboard'
+# additionnal classes the comment body should hold
+# adding "text-monospace" makes comment monospace
+TODO_COMMENT_CLASSES = []
+
+# The following two settings are relevant only if you want todo to track a support mailbox -
+# see Mail Tracking below.
+TODO_MAIL_BACKENDS
+TODO_MAIL_TRACKERS
```
The current django-todo version number is available from the [todo package](https://github.com/shacker/django-todo/blob/master/todo/__init__.py):
python -c "import todo; print(todo.__version__)"
+## Importing Tasks via CSV
+
+django-todo has the ability to batch-import ("upsert") tasks from a specifically formatted CSV spreadsheet. This ability is provided through both a management command and a web interface.
+
+**Management Command**
+
+`./manage.py import_csv -f /path/to/file.csv`
+
+**Web Importer**
+
+Link from your navigation to `{url "todo:import_csv"}`. Follow the resulting link for the CSV web upload view.
+
+
+### CSV Formatting
+
+Copy `todo/data/import_example.csv` to another location on your system and edit in a spreadsheet or directly.
+
+**Do not edit the header row!**
+
+The first four columns: `'Title', 'Group', 'Task List', 'Created By'` are required -- all others are optional and should work pretty much exactly like manual task entry via the web UI.
+
+Note: Internally, Tasks are keyed to TaskLists, not to Groups (TaskLists are in Gruops). However, we request the Group in the CSV
+because it's possible to have multiple TaskLists with the same name in different groups; i.e. we need it for namespacing and permissions.
+
+
+### Import Rules
+
+Because data entered via CSV is not going through the same view permissions enforced in the rest of django-todo, and to simplify data dependency logic, and to pre-empt disagreements between django-todo users, the importer will *not* create new users, groups, or task lists. All users, groups, and task lists referenced in your CSV must already exist, and group memberships must be correct.
+
+Any validation error (e.g. unparse-able dates, incorrect group memberships) **will result in that row being skipped.**
+
+A report of rows upserted and rows skipped (with line numbers and reasons) is provided at the end of the run.
+
+### Upsert Logic
+
+For each valid row, we need to decide whether to create a new task or update an existing one. django-todo matches on the unique combination of the four required columns. If we find a task that matches those, we *update* the rest of the columns. In other words, if you import a CSV once, then edit the Assigned To for a task and import it again, the original task will be updated with a new assignee (and same for the other columns).
+
+Otherwise we create a new task.
+
+
+## Mail Tracking
+
+What if you could turn django-todo into a shared mailbox? Django-todo includes an optional feature that allows emails
+sent to a dedicated mailbox to be pushed into todo as new tasks, and responses to be added as comments on those tasks.
+This allows support teams to work with a fully unified email + bug tracking system to avoid confusion over who's seen or
+responded to what.
+
+To enable mail tracking, you need to:
+
+ - Define an email backend for outgoing emails
+ - Define an email backend for incoming emails
+ - Start a worker, which will wait for new emails
+
+In settings:
+
+```python
+from todo.mail.producers import imap_producer
+from todo.mail.consumers import tracker_consumer
+from todo.mail.delivery import smtp_backend, console_backend
+
+# email notifications configuration
+# each task list can get its own delivery method
+TODO_MAIL_BACKENDS = {
+ # mail-queue is the name of the task list, not the worker name
+ "mail-queue": smtp_backend(
+ host="smtp.example.com",
+ port=465,
+ use_ssl=True,
+ username="test@example.com",
+ password="foobar",
+ # used as the From field when sending notifications.
+ # a username might be prepended later on
+ from_address="test@example.com",
+ # additionnal headers
+ headers={}
+ ),
+}
+
+# incoming mail worker configuration
+TODO_MAIL_TRACKERS = {
+ # configuration for worker "test_tracker"
+ "test_tracker": {
+ "producer": imap_producer(
+ host="imap.example.com",
+ username="text@example.com",
+ password="foobar",
+ # process_all=False, # by default, only unseen emails are processed
+ # preserve=False, # delete emails if False
+ # nap_duration=1, # duration of the pause between polling rounds
+ # input_folder="INBOX", # where to read emails from
+ ),
+ "consumer": tracker_consumer(
+ group="Mail Queuers",
+ task_list_slug="mail-queue",
+ priority=1,
+ task_title_format="[TEST_MAIL] {subject}",
+ )
+ }
+}
+```
+
+A mail worker can be started with:
+
+```sh
+./manage.py mail_worker test_tracker
+```
+
+Some views and URLs were renamed in 2.0 for logical consistency. If this affects you, see source code and the demo GTD site for reference to the new URL names.
+
+If you want to log mail events, make sure to properly configure django logging:
+
+```python
+LOGGING = {
+ 'version': 1,
+ 'disable_existing_loggers': False,
+ 'handlers': {
+ 'console': {
+ 'class': 'logging.StreamHandler',
+ },
+ },
+ 'loggers': {
+ '': {
+ 'handlers': ['console'],
+ 'level': 'DEBUG',
+ 'propagate': True,
+ },
+ },
+}
+```
+
+
+## Running Tests
+
+django-todo uses pytest exclusively for testing. The best way to run the suite is to clone django-todo into its own directory, install pytest, then:
+
+ pip install pytest pytest-django
+ pip install --editable .
+ pytest -x -v
+
+The previous `tox` system was removed with the v2 release, since we no longer aim to support older Python or Django versions.
+
+
## Upgrade Notes
django-todo 2.0 was rebuilt almost from the ground up, and included some radical changes, including model name changes. As a result, it is *not compatible* with data from django-todo 1.x. If you would like to upgrade an existing installation, try this:
@@ -151,62 +310,11 @@ That was the plan, but unfortunately, `makemigrations` created new tables and dr
django-todo no longer references a jQuery datepicker, but defaults to native html5 browser datepicker (not supported by Safari, unforunately). Feel free to implement one of your choosing.
-### URLs
-
-Some views and URLs were renamed in 2.0 for logical consistency. If this affects you, see source code and the demo GTD site for reference to the new URL names.
-
-
-## Running Tests
-
-django-todo uses pytest exclusively for testing. The best way to run the suite is to clone django-todo into its own directory, install pytest, then:
-
- pip install pytest pytest-django
- pip install --editable .
- pytest -x -v
-
-The previous `tox` system was removed with the v2 release, since we no longer aim to support older Python or Django versions.
-
-## Importing Tasks via CSV
-
-django-todo has the ability to batch-import ("upsert") tasks from a specifically formatted CSV spreadsheet. This ability is provided through both a management command and a web interface.
-
-**Management Command**
-
-`./manage.py import_csv -f /path/to/file.csv`
-
-**Web Importer**
-
-Link from your navigation to `{url "todo:import_csv"}`
-
-### Import Rules
-
-Because data entered via CSV is not going through the same view permissions enforced in the rest of django-todo, and to simplify data dependency logic, and to pre-empt disagreements between django-todo users, the importer will *not* create new users, groups, or task lists. All users, groups, and task lists referenced in your CSV must already exist, and group memberships must be correct.
-
-Any validation error (e.g. unparse-able dates, incorrect group memberships) **will result in that row being skipped.**
-
-A report of rows upserted and rows skipped (with line numbers and reasons) is provided at the end of the run.
-
-### CSV Formatting
-
-Copy `todo/data/import_example.csv` to another location on your system and edit in a spreadsheet or directly.
-
-**Do not edit the header row!**
-
-The first four columns: `'Title', 'Group', 'Task List', 'Created By'` are required -- all others are optional and should work pretty much exactly like manual task entry via the web UI.
-
-Note: Internally, Tasks are keyed to TaskLists, not to Groups (TaskLists are in Gruops). However, we request the Group in the CSV
-because it's possible to have multiple TaskLists with the same name in different groups; i.e. we need it for namespacing and permissions.
-
-### Upsert Logic
-
-For each valid row, we need to decide whether to create a new task or update an existing one. django-todo matches on the unique combination of the four required columns. If we find a task that matches those, we *update* the rest of the columns. In other words, if you import a CSV once, then edit the Assigned To for a task and import it again, the original task will be updated with a new assignee (and same for the other columns).
-
-Otherwise we create a new task.
-
-
## Version History
-**2.3.0** Added ability to batch-import tasks via CSV
+**2.3.0** Implement mail tracking system. Added ability to batch-import tasks via CSV. Fixed task re-ordering if task deleted behind the scenes.
+
+**2.2.2** Update dependencies
**2.2.1** Convert task delete and toggle_done views to POST only
@@ -268,4 +376,24 @@ ALL groups, not just the groups they "belong" to)
**0.9** - First release
+## Todo 2.0 Upgrade Notes
+django-todo 2.0 was rebuilt almost from the ground up, and included some radical changes, including model name changes. As a result, it is *not compatible* with data from django-todo 1.x. If you would like to upgrade an existing installation, try this:
+
+* Use `./manage.py dumpdata todo --indent 4 > todo.json` to export your old todo data
+* Edit the dump file, replacing the old model names `Item` and `List` with the new model names (`Task` and `TaskList`)
+* Delete your existing todo data
+* Uninstall the old todo app and reinstall
+* Migrate, then use `./manage.py loaddata todo.json` to import the edited data
+
+### Why not provide migrations?
+
+That was the plan, but unfortunately, `makemigrations` created new tables and dropped the old ones, making this a destructive update. Renaming models is unfortunately not something `makemigrations` can do, and I really didn't want to keep the badly named original models. Sorry!
+
+### Datepicker
+
+django-todo no longer references a jQuery datepicker, but defaults to native html5 browser datepicker (not supported by Safari, unforunately). Feel free to implement one of your choosing.
+
+### URLs
+
+Some views and URLs were renamed for logical consistency. If this affects you, see source code and the demo GTD site for reference to the new URL names.
\ No newline at end of file
From ad0a1aa44afe410e66030d8dfa173f1e2c37ade5 Mon Sep 17 00:00:00 2001
From: Scot Hacker
Date: Mon, 1 Apr 2019 23:17:57 -0700
Subject: [PATCH 110/210] Don't crash if CSV web importer does not receive a
file
---
todo/views/import_csv.py | 13 ++++++++++---
1 file changed, 10 insertions(+), 3 deletions(-)
diff --git a/todo/views/import_csv.py b/todo/views/import_csv.py
index 50db211..189eb58 100644
--- a/todo/views/import_csv.py
+++ b/todo/views/import_csv.py
@@ -1,10 +1,12 @@
+from django.contrib import messages
from django.contrib.auth.decorators import login_required, user_passes_test
from django.http import HttpResponse
-from django.shortcuts import render
-from todo.operations.csv_importer import CSVImporter
+from django.shortcuts import redirect, render, reverse
+from todo.operations.csv_importer import CSVImporter
from todo.utils import staff_check
+
@login_required
@user_passes_test(staff_check)
def import_csv(request) -> HttpResponse:
@@ -14,7 +16,12 @@ def import_csv(request) -> HttpResponse:
ctx = {}
if request.method == "POST":
- filepath = request.FILES.get('csvfile')
+ filepath = request.FILES.get("csvfile")
+
+ if not filepath:
+ messages.error(request, "You must supply a CSV file to import.")
+ return redirect(reverse("todo:import_csv"))
+
importer = CSVImporter()
results = importer.upsert(filepath)
ctx["results"] = results
From e9a7bbe48c90a6b505389082bec0ee461404b796 Mon Sep 17 00:00:00 2001
From: Scot Hacker
Date: Mon, 1 Apr 2019 23:41:04 -0700
Subject: [PATCH 111/210] Improve error handling for web upload of bad CSV
---
todo/operations/csv_importer.py | 4 ++--
todo/views/import_csv.py | 8 +++++++-
2 files changed, 9 insertions(+), 3 deletions(-)
diff --git a/todo/operations/csv_importer.py b/todo/operations/csv_importer.py
index 9fb3b18..7b847fa 100644
--- a/todo/operations/csv_importer.py
+++ b/todo/operations/csv_importer.py
@@ -54,10 +54,10 @@ class CSVImporter:
"Priority",
]
if header != expected:
- self.results.get("summaries").append(
+ self.errors.append(
f"Inbound data does not have expected columns.\nShould be: {expected}"
)
- return self.results
+ return
for row in csv_reader:
self.line_count += 1
diff --git a/todo/views/import_csv.py b/todo/views/import_csv.py
index 189eb58..def3564 100644
--- a/todo/views/import_csv.py
+++ b/todo/views/import_csv.py
@@ -24,6 +24,12 @@ def import_csv(request) -> HttpResponse:
importer = CSVImporter()
results = importer.upsert(filepath)
- ctx["results"] = results
+
+ ctx["results"] = None
+ if results:
+ ctx["results"] = results
+ else:
+ messages.error(request, "Could not parse provided CSV file.")
+ return redirect(reverse("todo:import_csv"))
return render(request, "todo/import_csv.html", context=ctx)
From 388fb40c00e4fba41707ad2e9dac7701f3b04b30 Mon Sep 17 00:00:00 2001
From: Scot Hacker
Date: Mon, 1 Apr 2019 23:48:54 -0700
Subject: [PATCH 112/210] Bump version to 2.3.1
---
README.md | 2 ++
todo/__init__.py | 2 +-
todo/views/import_csv.py | 3 +--
3 files changed, 4 insertions(+), 3 deletions(-)
diff --git a/README.md b/README.md
index 8860e01..dd490b9 100644
--- a/README.md
+++ b/README.md
@@ -312,6 +312,8 @@ django-todo no longer references a jQuery datepicker, but defaults to native htm
## Version History
+**2.3.1** Improve error handling for badly formatted or non-existent CSV uploads.
+
**2.3.0** Implement mail tracking system. Added ability to batch-import tasks via CSV. Fixed task re-ordering if task deleted behind the scenes.
**2.2.2** Update dependencies
diff --git a/todo/__init__.py b/todo/__init__.py
index 5d75298..59a7d90 100644
--- a/todo/__init__.py
+++ b/todo/__init__.py
@@ -1,7 +1,7 @@
"""
A multi-user, multi-group task management and assignment system for Django.
"""
-__version__ = '2.3.0'
+__version__ = '2.3.1'
__author__ = 'Scot Hacker'
__email__ = 'shacker@birdhouse.org'
diff --git a/todo/views/import_csv.py b/todo/views/import_csv.py
index def3564..720df5e 100644
--- a/todo/views/import_csv.py
+++ b/todo/views/import_csv.py
@@ -13,7 +13,7 @@ def import_csv(request) -> HttpResponse:
"""Import a specifically formatted CSV into stored tasks.
"""
- ctx = {}
+ ctx = {"results": None}
if request.method == "POST":
filepath = request.FILES.get("csvfile")
@@ -25,7 +25,6 @@ def import_csv(request) -> HttpResponse:
importer = CSVImporter()
results = importer.upsert(filepath)
- ctx["results"] = None
if results:
ctx["results"] = results
else:
From 8b448e88a5d7a9ba4c3b16a458cae815647b09dc Mon Sep 17 00:00:00 2001
From: Scot Hacker
Date: Tue, 2 Apr 2019 00:19:46 -0700
Subject: [PATCH 113/210] Update setup.py, bump to 2.3.2
---
README.md | 2 ++
setup.py | 47 +++++++++++++++++++++++++++++++----------------
2 files changed, 33 insertions(+), 16 deletions(-)
diff --git a/README.md b/README.md
index dd490b9..388e341 100644
--- a/README.md
+++ b/README.md
@@ -312,6 +312,8 @@ django-todo no longer references a jQuery datepicker, but defaults to native htm
## Version History
+**2.3.2** Update setup.py metadata
+
**2.3.1** Improve error handling for badly formatted or non-existent CSV uploads.
**2.3.0** Implement mail tracking system. Added ability to batch-import tasks via CSV. Fixed task re-ordering if task deleted behind the scenes.
diff --git a/setup.py b/setup.py
index 7f0ed19..a816258 100755
--- a/setup.py
+++ b/setup.py
@@ -1,31 +1,46 @@
-#!/usr/bin/env python
+# Based on setup.py master example at https://github.com/pypa/sampleproject/blob/master/setup.py
-from setuptools import setup, find_packages
+from io import open
+from os import path
-import todo as package
+from setuptools import find_packages, setup
+
+here = path.abspath(path.dirname(__file__))
+
+# Get the long description from the README file
+with open(path.join(here, "README.md"), encoding="utf-8") as f:
+ long_description = f.read()
setup(
name="django-todo",
- version=package.__version__,
- description=package.__doc__.strip(),
- author=package.__author__,
- author_email=package.__email__,
- url=package.__url__,
- license=package.__license__,
- packages=find_packages(),
+ version="2.3.2",
+ description="A multi-user, multi-group task management and assignment system for Django.",
+ long_description=long_description,
+ long_description_content_type="text/markdown",
+ url="https://github.com/shacker/django-todo",
+ author="Scot Hacker",
+ # For a list of valid classifiers, see https://pypi.org/classifiers/
classifiers=[
"Development Status :: 5 - Production/Stable",
- "Environment :: Web Environment",
- "Framework :: Django",
"Intended Audience :: Developers",
"License :: OSI Approved :: BSD License",
+ "Programming Language :: Python :: 3.6",
+ "Programming Language :: Python :: 3.7",
+ "Environment :: Web Environment",
+ "Framework :: Django",
"Operating System :: OS Independent",
- "Programming Language :: Python",
- "Programming Language :: Python :: 3",
+ "Topic :: Office/Business :: Groupware",
"Topic :: Office/Business :: Groupware",
"Topic :: Software Development :: Bug Tracking",
+ "Topic :: Software Development :: Bug Tracking",
],
- include_package_data=True,
- zip_safe=False,
+ keywords="lists todo bug bugs tracking",
+ packages=find_packages(exclude=["contrib", "docs", "tests"]),
+ python_requires=">=3.5",
install_requires=["unidecode"],
+ project_urls={
+ "Demo Site": "http://django-todo.org",
+ "Bug Reports": "https://github.com/shacker/django-todo/issues",
+ "Source": "https://github.com/shacker/django-todo",
+ },
)
From cdacc5fed548fc075ecd989e99e0ebdc1e37a3c1 Mon Sep 17 00:00:00 2001
From: Scot Hacker
Date: Tue, 2 Apr 2019 00:26:28 -0700
Subject: [PATCH 114/210] Remove redundant 2.0 upgrade notes
---
README.md | 21 ---------------------
1 file changed, 21 deletions(-)
diff --git a/README.md b/README.md
index 388e341..ed7275b 100644
--- a/README.md
+++ b/README.md
@@ -289,27 +289,6 @@ django-todo uses pytest exclusively for testing. The best way to run the suite i
pip install --editable .
pytest -x -v
-The previous `tox` system was removed with the v2 release, since we no longer aim to support older Python or Django versions.
-
-
-## Upgrade Notes
-
-django-todo 2.0 was rebuilt almost from the ground up, and included some radical changes, including model name changes. As a result, it is *not compatible* with data from django-todo 1.x. If you would like to upgrade an existing installation, try this:
-
-* Use `./manage.py dumpdata todo --indent 4 > todo.json` to export your old todo data
-* Edit the dump file, replacing the old model names `Item` and `List` with the new model names (`Task` and `TaskList`)
-* Delete your existing todo data
-* Uninstall the old todo app and reinstall
-* Migrate, then use `./manage.py loaddata todo.json` to import the edited data
-
-### Why not provide migrations?
-
-That was the plan, but unfortunately, `makemigrations` created new tables and dropped the old ones, making this a destructive update. Renaming models is unfortunately not something `makemigrations` can do, and I really didn't want to keep the badly named original models. Sorry!
-
-### Datepicker
-
-django-todo no longer references a jQuery datepicker, but defaults to native html5 browser datepicker (not supported by Safari, unforunately). Feel free to implement one of your choosing.
-
## Version History
**2.3.2** Update setup.py metadata
From 276ead54e71d278804bb3f5a93602a9876528f87 Mon Sep 17 00:00:00 2001
From: Scot Hacker
Date: Sat, 6 Apr 2019 16:30:01 -0700
Subject: [PATCH 115/210] Modeling and admin for attachment support
---
todo/admin.py | 11 +++++++++--
todo/migrations/0010_attachment.py | 28 ++++++++++++++++++++++++++++
todo/models.py | 23 +++++++++++++++++++++++
3 files changed, 60 insertions(+), 2 deletions(-)
create mode 100644 todo/migrations/0010_attachment.py
diff --git a/todo/admin.py b/todo/admin.py
index fa5d4ef..a8e6d13 100644
--- a/todo/admin.py
+++ b/todo/admin.py
@@ -1,18 +1,25 @@
from django.contrib import admin
-from todo.models import Task, TaskList, Comment
+
+from todo.models import Attachment, Comment, Task, TaskList
class TaskAdmin(admin.ModelAdmin):
list_display = ("title", "task_list", "completed", "priority", "due_date")
list_filter = ("task_list",)
ordering = ("priority",)
- search_fields = ("name",)
+ search_fields = ("title",)
class CommentAdmin(admin.ModelAdmin):
list_display = ("author", "date", "snippet")
+class AttachmentAdmin(admin.ModelAdmin):
+ list_display = ("task", "added_by", "timestamp", "file")
+ autocomplete_fields = ["added_by", "task"]
+
+
admin.site.register(TaskList)
admin.site.register(Comment, CommentAdmin)
admin.site.register(Task, TaskAdmin)
+admin.site.register(Attachment, AttachmentAdmin)
diff --git a/todo/migrations/0010_attachment.py b/todo/migrations/0010_attachment.py
new file mode 100644
index 0000000..885411a
--- /dev/null
+++ b/todo/migrations/0010_attachment.py
@@ -0,0 +1,28 @@
+# Generated by Django 2.2 on 2019-04-06 16:28
+
+import datetime
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import todo.models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ('todo', '0009_priority_optional'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Attachment',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('timestamp', models.DateTimeField(default=datetime.datetime.now)),
+ ('file', models.FileField(max_length=255, upload_to=todo.models.get_attachment_upload_dir)),
+ ('added_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ ('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='todo.Task')),
+ ],
+ ),
+ ]
diff --git a/todo/models.py b/todo/models.py
index 01bb19a..2151fc8 100644
--- a/todo/models.py
+++ b/todo/models.py
@@ -10,6 +10,13 @@ from django.urls import reverse
from django.utils import timezone
+def get_attachment_upload_dir(instance, filename):
+ """Determine upload dir for task attachment files.
+ """
+
+ return "/".join(["tasks", "attachments", str(instance.task.id), filename])
+
+
class LockedAtomicTransaction(Atomic):
"""
modified from https://stackoverflow.com/a/41831049
@@ -156,3 +163,19 @@ class Comment(models.Model):
def __str__(self):
return self.snippet
+
+
+class Attachment(models.Model):
+ """
+ Defines a generic file attachment for use in M2M relation with Task.
+ """
+
+ task = models.ForeignKey(Task, on_delete=models.CASCADE)
+ added_by = models.ForeignKey(
+ settings.AUTH_USER_MODEL, on_delete=models.CASCADE
+ )
+ timestamp = models.DateTimeField(default=datetime.datetime.now)
+ file = models.FileField(upload_to=get_attachment_upload_dir, max_length=255)
+
+ def __str__(self):
+ return f"{self.task.id} - {self.file.name}"
From 9a5c794c41878d6fa745655b5b3c77aac36b7239 Mon Sep 17 00:00:00 2001
From: Scot Hacker
Date: Sat, 6 Apr 2019 16:39:34 -0700
Subject: [PATCH 116/210] Formatting
---
todo/utils.py | 62 +++++++++++++++++++++------------------------------
1 file changed, 26 insertions(+), 36 deletions(-)
diff --git a/todo/utils.py b/todo/utils.py
index c5b58a4..d87b609 100644
--- a/todo/utils.py
+++ b/todo/utils.py
@@ -1,14 +1,16 @@
import email.utils
-import functools
import time
+import logging
+
from django.conf import settings
from django.contrib.sites.models import Site
from django.core import mail
-from django.core.exceptions import PermissionDenied
-from django.core.mail import send_mail
from django.template.loader import render_to_string
+
from todo.models import Comment, Task
+log = logging.getLogger(__name__)
+
def staff_check(user):
"""If TODO_STAFF_ONLY is set to True, limit view access to staff users only.
@@ -28,7 +30,7 @@ def user_can_read_task(task, user):
def todo_get_backend(task):
- '''returns a mail backend for some task'''
+ """Returns a mail backend for some task"""
mail_backends = getattr(settings, "TODO_MAIL_BACKENDS", None)
if mail_backends is None:
return None
@@ -41,7 +43,7 @@ def todo_get_backend(task):
def todo_get_mailer(user, task):
- """a mailer is a (from_address, backend) pair"""
+ """A mailer is a (from_address, backend) pair"""
task_backend = todo_get_backend(task)
if task_backend is None:
return (None, mail.get_connection)
@@ -52,19 +54,13 @@ def todo_get_mailer(user, task):
def todo_send_mail(user, task, subject, body, recip_list):
- '''Send an email attached to task, triggered by user'''
- references = Comment.objects.filter(task=task).only('email_message_id')
+ """Send an email attached to task, triggered by user"""
+ references = Comment.objects.filter(task=task).only("email_message_id")
references = (ref.email_message_id for ref in references)
- references = ' '.join(filter(bool, references))
+ references = " ".join(filter(bool, references))
from_address, backend = todo_get_mailer(user, task)
- message_hash = hash((
- subject,
- body,
- from_address,
- frozenset(recip_list),
- references,
- ))
+ message_hash = hash((subject, body, from_address, frozenset(recip_list), references))
message_id = (
# the task_id enables attaching back notification answers
@@ -76,14 +72,14 @@ def todo_send_mail(user, task, subject, body, recip_list):
task_id=task.pk,
# avoid the -hexstring case (hashes can be negative)
message_hash=abs(message_hash),
- epoch=int(time.time())
+ epoch=int(time.time()),
)
# the thread message id is used as a common denominator between all
# notifications for some task. This message doesn't actually exist,
# it's just there to make threading possible
thread_message_id = "".format(task.pk)
- references = '{} {}'.format(references, thread_message_id)
+ references = "{} {}".format(references, thread_message_id)
with backend() as connection:
message = mail.EmailMessage(
@@ -91,12 +87,12 @@ def todo_send_mail(user, task, subject, body, recip_list):
body,
from_address,
recip_list,
- [], # Bcc
+ [], # Bcc
headers={
- **getattr(backend, 'headers', {}),
- 'Message-ID': message_id,
- 'References': references,
- 'In-reply-to': thread_message_id,
+ **getattr(backend, "headers", {}),
+ "Message-ID": message_id,
+ "References": references,
+ "In-reply-to": thread_message_id,
},
connection=connection,
)
@@ -104,10 +100,10 @@ def todo_send_mail(user, task, subject, body, recip_list):
def send_notify_mail(new_task):
- '''
+ """
Send email to assignee if task is assigned to someone other than submittor.
Unassigned tasks should not try to notify.
- '''
+ """
if new_task.assigned_to == new_task.created_by:
return
@@ -123,15 +119,12 @@ def send_notify_mail(new_task):
def send_email_to_thread_participants(task, msg_body, user, subject=None):
- '''Notify all previous commentors on a Task about a new comment.'''
+ """Notify all previous commentors on a Task about a new comment."""
current_site = Site.objects.get_current()
email_subject = subject
if not subject:
- subject = render_to_string(
- "todo/email/assigned_subject.txt",
- {"task": task}
- )
+ subject = render_to_string("todo/email/assigned_subject.txt", {"task": task})
email_body = render_to_string(
"todo/email/newcomment_body.txt",
@@ -140,11 +133,7 @@ def send_email_to_thread_participants(task, msg_body, user, subject=None):
# Get all thread participants
commenters = Comment.objects.filter(task=task)
- recip_list = set(
- ca.author.email
- for ca in commenters
- if ca.author is not None
- )
+ recip_list = set(ca.author.email for ca in commenters if ca.author is not None)
for related_user in (task.created_by, task.assigned_to):
if related_user is not None:
recip_list.add(related_user.email)
@@ -154,12 +143,13 @@ def send_email_to_thread_participants(task, msg_body, user, subject=None):
def toggle_task_completed(task_id: int) -> bool:
+ """Toggle the `completed` bool on Task from True to False or vice versa."""
try:
task = Task.objects.get(id=task_id)
task.completed = not task.completed
task.save()
return True
+
except Task.DoesNotExist:
- # FIXME proper log message
- print("task not found")
+ log.info(f"Task {task_id} not found.")
return False
From b6c2227417b363b70875cf43013d0832c5dca89c Mon Sep 17 00:00:00 2001
From: Scot Hacker
Date: Sun, 7 Apr 2019 16:11:19 -0700
Subject: [PATCH 117/210] Working file upload dialog and receive in view
---
todo/models.py | 11 ++++-
todo/templates/todo/task_detail.html | 62 +++++++++++++++++++++++++
todo/views/task_detail.py | 69 ++++++++++++++++------------
3 files changed, 111 insertions(+), 31 deletions(-)
diff --git a/todo/models.py b/todo/models.py
index 2151fc8..a9582dc 100644
--- a/todo/models.py
+++ b/todo/models.py
@@ -1,10 +1,12 @@
from __future__ import unicode_literals
+
import datetime
+import os
import textwrap
from django.conf import settings
from django.contrib.auth.models import Group
-from django.db import models, DEFAULT_DB_ALIAS
+from django.db import DEFAULT_DB_ALIAS, models
from django.db.transaction import Atomic, get_connection
from django.urls import reverse
from django.utils import timezone
@@ -177,5 +179,12 @@ class Attachment(models.Model):
timestamp = models.DateTimeField(default=datetime.datetime.now)
file = models.FileField(upload_to=get_attachment_upload_dir, max_length=255)
+ def filename(self):
+ return os.path.basename(self.file.name)
+
+ def extension(self):
+ name, extension = os.path.splitext(self.file.name)
+ return extension
+
def __str__(self):
return f"{self.task.id} - {self.file.name}"
diff --git a/todo/templates/todo/task_detail.html b/todo/templates/todo/task_detail.html
index a1f8d29..eea9c91 100644
--- a/todo/templates/todo/task_detail.html
+++ b/todo/templates/todo/task_detail.html
@@ -115,6 +115,55 @@
{% endif %}
+{% if attachments_enabled %}
+
+
+ Attachments
+
+
+
+ {% if task.attachment_set.count %}
+
+
+
+
+
File
+
Uploaded
+
By
+
Type
+
+
+
+ {% for attachment in task.attachment_set.all %}
+
{% endblock %}
+
+{% block extra_js %}
+ {# Support file attachment uploader #}
+
+{% endblock extra_js %}
+
diff --git a/todo/views/task_detail.py b/todo/views/task_detail.py
index 8ded8d8..4351130 100644
--- a/todo/views/task_detail.py
+++ b/todo/views/task_detail.py
@@ -1,25 +1,27 @@
-import bleach
import datetime
+import bleach
from django import forms
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required, user_passes_test
from django.core.exceptions import PermissionDenied
from django.http import HttpResponse
-from django.shortcuts import get_object_or_404, redirect, render, redirect
+from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
-from django.utils.decorators import method_decorator
-from todo.forms import AddEditTaskForm
-from todo.models import Comment, Task
-from todo.utils import send_email_to_thread_participants, toggle_task_completed, staff_check, user_can_read_task
from todo.features import HAS_TASK_MERGE
-
+from todo.forms import AddEditTaskForm
+from todo.models import Attachment, Comment, Task
+from todo.utils import (
+ send_email_to_thread_participants,
+ staff_check,
+ toggle_task_completed,
+ user_can_read_task,
+)
if HAS_TASK_MERGE:
from dal import autocomplete
- from todo.views.task_autocomplete import TaskAutocomplete
def handle_add_comment(request, task):
@@ -27,9 +29,7 @@ def handle_add_comment(request, task):
return
Comment.objects.create(
- author=request.user,
- task=task,
- body=bleach.clean(request.POST["comment-body"], strip=True),
+ author=request.user, task=task, body=bleach.clean(request.POST["comment-body"], strip=True)
)
send_email_to_thread_participants(
@@ -39,9 +39,7 @@ def handle_add_comment(request, task):
subject='New comment posted on task "{}"'.format(task.title),
)
- messages.success(
- request, "Comment posted. Notification email sent to thread participants."
- )
+ messages.success(request, "Comment posted. Notification email sent to thread participants.")
@login_required
@@ -51,7 +49,7 @@ def task_detail(request, task_id: int) -> HttpResponse:
"""
task = get_object_or_404(Task, pk=task_id)
- comment_list = Comment.objects.filter(task=task_id).order_by('-date')
+ comment_list = Comment.objects.filter(task=task_id).order_by("-date")
# Ensure user has permission to view task. Admins can view all tasks.
# Get the group this task belongs to, and check whether current user is a member of that group.
@@ -62,6 +60,7 @@ def task_detail(request, task_id: int) -> HttpResponse:
if not HAS_TASK_MERGE:
merge_form = None
else:
+
class MergeForm(forms.Form):
merge_target = forms.ModelChoiceField(
queryset=Task.objects.all(),
@@ -81,25 +80,17 @@ def task_detail(request, task_id: int) -> HttpResponse:
raise PermissionDenied
task.merge_into(merge_target)
- return redirect(reverse(
- "todo:task_detail",
- kwargs={"task_id": merge_target.pk}
- ))
+ return redirect(reverse("todo:task_detail", kwargs={"task_id": merge_target.pk}))
# Save submitted comments
handle_add_comment(request, task)
# Save task edits
if not request.POST.get("add_edit_task"):
- form = AddEditTaskForm(
- request.user, instance=task, initial={"task_list": task.task_list}
- )
+ form = AddEditTaskForm(request.user, instance=task, initial={"task_list": task.task_list})
else:
form = AddEditTaskForm(
- request.user,
- request.POST,
- instance=task,
- initial={"task_list": task.task_list},
+ request.user, request.POST, instance=task, initial={"task_list": task.task_list}
)
if form.is_valid():
@@ -108,9 +99,7 @@ def task_detail(request, task_id: int) -> HttpResponse:
item.save()
messages.success(request, "The task has been edited.")
return redirect(
- "todo:list_detail",
- list_id=task.task_list.id,
- list_slug=task.task_list.slug,
+ "todo:list_detail", list_id=task.task_list.id, list_slug=task.task_list.slug
)
# Mark complete
@@ -126,13 +115,33 @@ def task_detail(request, task_id: int) -> HttpResponse:
else:
thedate = datetime.datetime.now()
+ # Handle uploaded files
+ if request.FILES.get("attachment_file_input"):
+ Attachment.objects.create(
+ task=task,
+ added_by=request.user,
+ timestamp=datetime.datetime.now(),
+ file=request.FILES.get("attachment_file_input"),
+ )
+ return redirect("todo:task_detail", task_id=task.id)
+
+ # For the context: Settings for file attachments defaults to True
+ # FIXME: Move settings defaults to a central location?
+ attachments_enabled = True
+ if (
+ hasattr(settings, "TODO_ALLOW_FILE_ATTACHMENTS")
+ and not settings.TODO_ALLOW_FILE_ATTACHMENTS
+ ):
+ attachments_enabled = False
+
context = {
"task": task,
"comment_list": comment_list,
"form": form,
"merge_form": merge_form,
"thedate": thedate,
- "comment_classes": getattr(settings, 'TODO_COMMENT_CLASSES', []),
+ "comment_classes": getattr(settings, "TODO_COMMENT_CLASSES", []),
+ "attachments_enabled": attachments_enabled,
}
return render(request, "todo/task_detail.html", context)
From 8cd169e5029a19da3d1bd1521ab246500427cd91 Mon Sep 17 00:00:00 2001
From: Scot Hacker
Date: Sun, 7 Apr 2019 16:11:28 -0700
Subject: [PATCH 118/210] MIsc tweaks
---
todo/features.py | 4 ++++
todo/templates/todo/import_csv.html | 1 -
todo/utils.py | 4 ++--
3 files changed, 6 insertions(+), 3 deletions(-)
diff --git a/todo/features.py b/todo/features.py
index b55a9ea..8cd084e 100644
--- a/todo/features.py
+++ b/todo/features.py
@@ -1,3 +1,7 @@
+# The integrated mail queue functionality can enable advanced functionality if
+# django-autocomplete-light is installed and configured. We can use this module
+# to check for other installed dependencies in the future.
+
HAS_AUTOCOMPLETE = True
try:
import dal
diff --git a/todo/templates/todo/import_csv.html b/todo/templates/todo/import_csv.html
index 121925b..9d5cb85 100644
--- a/todo/templates/todo/import_csv.html
+++ b/todo/templates/todo/import_csv.html
@@ -66,7 +66,6 @@
{% endif %}
-
Upload Tasks
diff --git a/todo/utils.py b/todo/utils.py
index d87b609..0016a53 100644
--- a/todo/utils.py
+++ b/todo/utils.py
@@ -14,8 +14,8 @@ log = logging.getLogger(__name__)
def staff_check(user):
"""If TODO_STAFF_ONLY is set to True, limit view access to staff users only.
- # FIXME: More granular access control is needed... but need to do it generically,
- # to satisfy all possible todo implementations.
+ # FIXME: More granular access control needed - see
+ https://github.com/shacker/django-todo/issues/50
"""
if hasattr(settings, "TODO_STAFF_ONLY") and settings.TODO_STAFF_ONLY:
From ab929b07e1cf8dee95f5fb38f0adc67bcbac701f Mon Sep 17 00:00:00 2001
From: Scot Hacker
Date: Sun, 7 Apr 2019 23:55:31 -0700
Subject: [PATCH 119/210] Limit attachments to specified file types
---
README.md | 10 +++++++++-
todo/templates/todo/task_detail.html | 4 ++--
todo/views/task_detail.py | 22 +++++++++++++++++-----
3 files changed, 28 insertions(+), 8 deletions(-)
diff --git a/README.md b/README.md
index ed7275b..f1f9a54 100644
--- a/README.md
+++ b/README.md
@@ -15,6 +15,7 @@ assignment application for Django, designed to be dropped into an existing site
* Mobile-friendly (work in progress)
* Separate view for My Tasks (across lists)
* Batch-import tasks via CSV
+* Multiple file attachments per task (see settings)
* Integrated mail tracking (unify a task list with an email box)
@@ -39,6 +40,8 @@ Identical list names can exist in different groups, but not in the same group.
Emails are generated to the assigned-to person when new tasks are created.
+File attachments of a few types are allowed on tasks by default. See settings to disable or to limit filetypes.
+
Comment threads can be added to tasks. Each participant in a thread receives email when new comments are added.
django-todo is auth-only. You must set up a login system and at least one group before deploying.
@@ -101,7 +104,7 @@ Add links to your site's navigation system:
My Tasks
```
-django-todo makes use of the Django `messages` system. Make sure you have something like [this](https://docs.djangoproject.com/en/2.0/ref/contrib/messages/#displaying-messages) (link) in your `base.html`.
+django-todo makes use of the Django `messages` system. Make sure you have something like [this](https://docs.djangoproject.com/en/2.1/ref/contrib/messages/#displaying-messages) (link) in your `base.html`.
Log in and access `/todo`!
@@ -136,6 +139,11 @@ TODO_DEFAULT_LIST_SLUG = 'tickets'
# Defaults to "/"
TODO_PUBLIC_SUBMIT_REDIRECT = 'dashboard'
+# Enable or disable file attachments on Tasks
+# Optionally limit list of allowed filetypes
+TODO_ALLOW_FILE_ATTACHMENTS = True
+TODO_ALLOWED_FILE_ATTACHMENTS = [".jpg", ".gif", ".csv", ".pdf", ".zip"]
+
# additionnal classes the comment body should hold
# adding "text-monospace" makes comment monospace
TODO_COMMENT_CLASSES = []
diff --git a/todo/templates/todo/task_detail.html b/todo/templates/todo/task_detail.html
index eea9c91..1a3fd84 100644
--- a/todo/templates/todo/task_detail.html
+++ b/todo/templates/todo/task_detail.html
@@ -124,7 +124,7 @@
{% if task.attachment_set.count %}
-
+
File
@@ -147,7 +147,7 @@
{% endif %}
-
+
{% csrf_token %}
diff --git a/todo/views/task_detail.py b/todo/views/task_detail.py
index 4351130..2d10844 100644
--- a/todo/views/task_detail.py
+++ b/todo/views/task_detail.py
@@ -1,4 +1,5 @@
import datetime
+import os
import bleach
from django import forms
@@ -117,15 +118,26 @@ def task_detail(request, task_id: int) -> HttpResponse:
# Handle uploaded files
if request.FILES.get("attachment_file_input"):
+ file = request.FILES.get("attachment_file_input")
+
+ # Validate inbound file extension against allowed filetypes
+ # FIXME: Move defaults to centralized module
+ allowed_extensions = (
+ settings.TODO_ALLOWED_FILE_ATTACHMENTS
+ if hasattr(settings, "TODO_ALLOWED_FILE_ATTACHMENTS")
+ else [".jpg", ".gif", ".csv", ".pdf", ".zip"]
+ )
+ name, extension = os.path.splitext(file.name)
+ if extension not in allowed_extensions:
+ messages.error(request, f"This site does not allow upload of {extension} files.")
+ return redirect("todo:task_detail", task_id=task.id)
+
Attachment.objects.create(
- task=task,
- added_by=request.user,
- timestamp=datetime.datetime.now(),
- file=request.FILES.get("attachment_file_input"),
+ task=task, added_by=request.user, timestamp=datetime.datetime.now(), file=file
)
return redirect("todo:task_detail", task_id=task.id)
- # For the context: Settings for file attachments defaults to True
+ # Settings for file attachments defaults to True
# FIXME: Move settings defaults to a central location?
attachments_enabled = True
if (
From 2e021637013cc3dce196e1a4af977e6cda73043d Mon Sep 17 00:00:00 2001
From: Scot Hacker
Date: Sun, 7 Apr 2019 23:57:48 -0700
Subject: [PATCH 120/210] readme tweak
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index f1f9a54..31124f4 100644
--- a/README.md
+++ b/README.md
@@ -40,7 +40,7 @@ Identical list names can exist in different groups, but not in the same group.
Emails are generated to the assigned-to person when new tasks are created.
-File attachments of a few types are allowed on tasks by default. See settings to disable or to limit filetypes.
+File attachments of a few types are allowed on tasks by default. See settings to disable or to limit filetypes. If you are concerned about file sizes, limit them in your web server configuration (not currently handled separately by django-todo).
Comment threads can be added to tasks. Each participant in a thread receives email when new comments are added.
From e7655ccfe893df94a30bbc95fdb22a789bed82a0 Mon Sep 17 00:00:00 2001
From: Scot Hacker
Date: Mon, 8 Apr 2019 23:46:34 -0700
Subject: [PATCH 121/210] Implement attachment removal
---
todo/templates/todo/task_detail.html | 7 +++++
todo/urls.py | 5 ++++
todo/utils.py | 21 ++++++++++++--
todo/views/__init__.py | 3 +-
todo/views/remove_attachment.py | 41 ++++++++++++++++++++++++++++
5 files changed, 74 insertions(+), 3 deletions(-)
create mode 100644 todo/views/remove_attachment.py
diff --git a/todo/templates/todo/task_detail.html b/todo/templates/todo/task_detail.html
index 1a3fd84..8eca361 100644
--- a/todo/templates/todo/task_detail.html
+++ b/todo/templates/todo/task_detail.html
@@ -131,6 +131,7 @@
Uploaded
By
Type
+
Remove
@@ -140,6 +141,12 @@
{{ attachment.timestamp }}
{{ attachment.added_by.get_full_name }}
{{ attachment.extension.lower }}
+
+
+ {% csrf_token %}
+
+
+
{% endfor %}
diff --git a/todo/urls.py b/todo/urls.py
index 0573def..a539616 100644
--- a/todo/urls.py
+++ b/todo/urls.py
@@ -56,6 +56,11 @@ urlpatterns = [
'task//',
views.task_detail,
name='task_detail'),
+
+ path(
+ 'attachment/remove//',
+ views.remove_attachment,
+ name='remove_attachment'),
]
if HAS_TASK_MERGE:
diff --git a/todo/utils.py b/todo/utils.py
index 0016a53..23c0e01 100644
--- a/todo/utils.py
+++ b/todo/utils.py
@@ -1,13 +1,14 @@
import email.utils
-import time
import logging
+import os
+import time
from django.conf import settings
from django.contrib.sites.models import Site
from django.core import mail
from django.template.loader import render_to_string
-from todo.models import Comment, Task
+from todo.models import Attachment, Comment, Task
log = logging.getLogger(__name__)
@@ -153,3 +154,19 @@ def toggle_task_completed(task_id: int) -> bool:
except Task.DoesNotExist:
log.info(f"Task {task_id} not found.")
return False
+
+
+def remove_attachment_file(attachment_id: int) -> bool:
+ """Delete an Attachment object and its corresponding file from the filesystem."""
+ try:
+ attachment = Attachment.objects.get(id=attachment_id)
+ if attachment.file:
+ if os.path.isfile(attachment.file.path):
+ os.remove(attachment.file.path)
+
+ attachment.delete()
+ return True
+
+ except Attachment.DoesNotExist:
+ log.info(f"Attachment {attachment_id} not found.")
+ return False
diff --git a/todo/views/__init__.py b/todo/views/__init__.py
index 4c5777d..fb6c891 100644
--- a/todo/views/__init__.py
+++ b/todo/views/__init__.py
@@ -2,10 +2,11 @@ from todo.views.add_list import add_list # noqa: F401
from todo.views.del_list import del_list # noqa: F401
from todo.views.delete_task import delete_task # noqa: F401
from todo.views.external_add import external_add # noqa: F401
+from todo.views.import_csv import import_csv # noqa: F401
from todo.views.list_detail import list_detail # noqa: F401
from todo.views.list_lists import list_lists # noqa: F401
+from todo.views.remove_attachment import remove_attachment # noqa: F401
from todo.views.reorder_tasks import reorder_tasks # noqa: F401
from todo.views.search import search # noqa: F401
from todo.views.task_detail import task_detail # noqa: F401
from todo.views.toggle_done import toggle_done # noqa: F401
-from todo.views.import_csv import import_csv # noqa: F401
diff --git a/todo/views/remove_attachment.py b/todo/views/remove_attachment.py
new file mode 100644
index 0000000..75b235f
--- /dev/null
+++ b/todo/views/remove_attachment.py
@@ -0,0 +1,41 @@
+from django.contrib import messages
+from django.contrib.auth.decorators import login_required
+from django.core.exceptions import PermissionDenied
+from django.http import HttpResponse
+from django.shortcuts import get_object_or_404, redirect
+from django.urls import reverse
+
+from todo.models import Attachment
+from todo.utils import remove_attachment_file
+
+
+@login_required
+def remove_attachment(request, attachment_id: int) -> HttpResponse:
+ """Delete a previously posted attachment object and its corresponding file
+ from the filesystem, permissions allowing.
+ """
+
+ if request.method == "POST":
+ attachment = get_object_or_404(Attachment, pk=attachment_id)
+
+ redir_url = reverse(
+ "todo:task_detail",
+ kwargs={"task_id": attachment.task.id},
+ )
+
+ # Permissions
+ if not (
+ attachment.task.task_list.group in request.user.groups.all()
+ or request.user.is_superuser
+ ):
+ raise PermissionDenied
+
+ if remove_attachment_file(attachment.id):
+ messages.success(request, f"Attachment {attachment.id} removed.")
+ else:
+ messages.error(request, f"Sorry, there was a problem deleting attachment {attachment.id}.")
+
+ return redirect(redir_url)
+
+ else:
+ raise PermissionDenied
From fb94fdb130c50264de3cdc2630a0bbcae62fb949 Mon Sep 17 00:00:00 2001
From: Scot Hacker
Date: Tue, 9 Apr 2019 00:01:27 -0700
Subject: [PATCH 122/210] Bump version, README for 2.4.0
---
README.md | 2 ++
setup.py | 2 +-
todo/tests/test_utils.py | 3 +++
3 files changed, 6 insertions(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 31124f4..472d5e7 100644
--- a/README.md
+++ b/README.md
@@ -299,6 +299,8 @@ django-todo uses pytest exclusively for testing. The best way to run the suite i
## Version History
+**2.4.0** Implement optional file attachments on tasks
+
**2.3.2** Update setup.py metadata
**2.3.1** Improve error handling for badly formatted or non-existent CSV uploads.
diff --git a/setup.py b/setup.py
index a816258..d1abd2b 100755
--- a/setup.py
+++ b/setup.py
@@ -13,7 +13,7 @@ with open(path.join(here, "README.md"), encoding="utf-8") as f:
setup(
name="django-todo",
- version="2.3.2",
+ version="2.4.0",
description="A multi-user, multi-group task management and assignment system for Django.",
long_description=long_description,
long_description_content_type="text/markdown",
diff --git a/todo/tests/test_utils.py b/todo/tests/test_utils.py
index 4cc0008..48f3149 100644
--- a/todo/tests/test_utils.py
+++ b/todo/tests/test_utils.py
@@ -54,3 +54,6 @@ def test_send_email_to_thread_participants(todo_setup, django_user_model, email_
assert "u1@example.com" in mail.outbox[0].recipients()
assert "u3@example.com" in mail.outbox[0].recipients()
assert "u4@example.com" in mail.outbox[0].recipients()
+
+# FIXME: Add tests for:
+# Attachments: Test whether allowed, test multiple, test extensions
\ No newline at end of file
From 602cf247e2e5b3280d77d6cf725798401b655e69 Mon Sep 17 00:00:00 2001
From: Scot Hacker
Date: Wed, 10 Apr 2019 00:28:00 -0700
Subject: [PATCH 123/210] Resolve setup.py package installation issues, bump
version
---
MANIFEST.in | 3 ++-
setup.py | 7 ++++---
2 files changed, 6 insertions(+), 4 deletions(-)
diff --git a/MANIFEST.in b/MANIFEST.in
index a13f7f3..b33eba6 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,4 +1,5 @@
include LICENSE
-include README.rst
+include README.md
+recursive-include todo/data *
recursive-include todo/static *
recursive-include todo/templates *
diff --git a/setup.py b/setup.py
index d1abd2b..19e97da 100755
--- a/setup.py
+++ b/setup.py
@@ -3,7 +3,7 @@
from io import open
from os import path
-from setuptools import find_packages, setup
+from setuptools import setup, find_packages
here = path.abspath(path.dirname(__file__))
@@ -13,7 +13,7 @@ with open(path.join(here, "README.md"), encoding="utf-8") as f:
setup(
name="django-todo",
- version="2.4.0",
+ version="2.4.4",
description="A multi-user, multi-group task management and assignment system for Django.",
long_description=long_description,
long_description_content_type="text/markdown",
@@ -35,7 +35,8 @@ setup(
"Topic :: Software Development :: Bug Tracking",
],
keywords="lists todo bug bugs tracking",
- packages=find_packages(exclude=["contrib", "docs", "tests"]),
+ packages=find_packages(), # Finds modules with an __init__.py
+ include_package_data=True, # Pulls in non-module data from MANIFEST.in
python_requires=">=3.5",
install_requires=["unidecode"],
project_urls={
From 8a20998f8c0a54bc5626a40beb99294916130723 Mon Sep 17 00:00:00 2001
From: james1293
Date: Thu, 11 Apr 2019 03:05:44 -0400
Subject: [PATCH 124/210] Re-enable name="notify" to task editor to enable
notifications (#67)
---
todo/templates/todo/include/task_edit.html | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/todo/templates/todo/include/task_edit.html b/todo/templates/todo/include/task_edit.html
index 936d465..078d25d 100644
--- a/todo/templates/todo/include/task_edit.html
+++ b/todo/templates/todo/include/task_edit.html
@@ -32,7 +32,7 @@
- {{ comment.author.first_name }} {{ comment.author.last_name }}, - {{ comment.date|date:"F d Y P" }} - -
- {{ comment.body|safe|urlize|linebreaks }} - {% empty %} -No Comments
- {% endfor %} -