From 78e9c510bcb5148f6d34419a27fd96bbbd94a921 Mon Sep 17 00:00:00 2001 From: Scot Hacker Date: Fri, 21 Dec 2018 02:00:36 -0800 Subject: [PATCH] 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}, + ) + )