From 50d182103c46a195afc8ad64c5948d8419e17ece Mon Sep 17 00:00:00 2001 From: Scot Hacker Date: Sat, 7 Apr 2018 13:42:34 -0700 Subject: [PATCH 001/141] 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 002/141] 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 003/141] 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 %} - + @@ -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 016/141] 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 017/141] 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 018/141] 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 019/141] 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 020/141] 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 021/141] 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 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`. +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 022/141] 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 023/141] 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 024/141] 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 025/141] 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 026/141] 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 027/141] 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 028/141] 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 %} + + + {% endfor %}
Done Task Created - + + {% csrf_token %} +
diff --git a/todo/templates/todo/task_detail.html b/todo/templates/todo/task_detail.html index 067d7ae..eddb130 100644 --- a/todo/templates/todo/task_detail.html +++ b/todo/templates/todo/task_detail.html @@ -12,21 +12,40 @@
-