├── todo ├── mail │ ├── __init__.py │ ├── producers │ │ ├── __init__.py │ │ └── imap.py │ ├── consumers │ │ ├── __init__.py │ │ └── tracker.py │ └── delivery.py ├── tests │ ├── __init__.py │ ├── data │ │ └── csv_import_data.csv │ ├── conftest.py │ ├── test_import.py │ ├── test_utils.py │ ├── test_tracker.py │ └── test_views.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── mail_worker.py │ │ ├── import_csv.py │ │ └── hopper.py ├── migrations │ ├── __init__.py │ ├── 0006_rename_item_model.py │ ├── 0007_auto_update_created_date.py │ ├── 0002_auto_20150614_2339.py │ ├── 0005_auto_20180212_2325.py │ ├── 0009_priority_optional.py │ ├── 0011_add_related_name_to_created_by.py │ ├── 0012_add_related_name_to_comments.py │ ├── 0003_assignee_optional.py │ ├── 0010_attachment.py │ ├── 0008_mail_tracker.py │ ├── 0004_rename_list_tasklist.py │ └── 0001_initial.py ├── operations │ ├── __init__.py │ └── csv_importer.py ├── static │ └── todo │ │ ├── css │ │ └── styles.css │ │ └── js │ │ └── jquery.tablednd_0_5.js ├── templates │ ├── base.html │ └── todo │ │ ├── email │ │ ├── assigned_subject.txt │ │ ├── newcomment_body.txt │ │ └── assigned_body.txt │ │ ├── base.html │ │ ├── include │ │ ├── toggle_delete.html │ │ └── task_edit.html │ │ ├── add_list.html │ │ ├── search_results.html │ │ ├── list_lists.html │ │ ├── del_list.html │ │ ├── add_task_external.html │ │ ├── import_csv.html │ │ ├── list_detail.html │ │ └── task_detail.html ├── __init__.py ├── data │ └── import_example.csv ├── features.py ├── check.py ├── views │ ├── __init__.py │ ├── task_autocomplete.py │ ├── import_csv.py │ ├── reorder_tasks.py │ ├── delete_task.py │ ├── remove_attachment.py │ ├── toggle_done.py │ ├── search.py │ ├── del_list.py │ ├── list_lists.py │ ├── add_list.py │ ├── list_detail.py │ ├── external_add.py │ └── task_detail.py ├── defaults.py ├── admin.py ├── urls.py ├── forms.py ├── utils.py └── models.py ├── setup.py ├── mkdocs.yml ├── pyproject.toml ├── pytest.ini ├── MANIFEST.in ├── SECURITY.md ├── .gitignore ├── base_urls.py ├── setup.cfg ├── LICENSE ├── test_settings.py ├── docs └── index.md └── README.md /todo/mail/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /todo/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /todo/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /todo/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /todo/operations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /todo/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup() 4 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: "readthedocs" 2 | theme: "readthedocs" 3 | -------------------------------------------------------------------------------- /todo/static/todo/css/styles.css: -------------------------------------------------------------------------------- 1 | label { 2 | display: block; 3 | font-weight: bold; 4 | } 5 | -------------------------------------------------------------------------------- /todo/templates/base.html: -------------------------------------------------------------------------------- 1 | This file not actually used by django-todo - here to satisfy the test runner. -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ['setuptools>=40.8.0', 'wheel'] 3 | build-backend = 'setuptools.build_meta:__legacy__' 4 | -------------------------------------------------------------------------------- /todo/templates/todo/email/assigned_subject.txt: -------------------------------------------------------------------------------- 1 | A new task has been assigned to you - {% autoescape off %}{{ task.title }}{% endautoescape %} -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE = test_settings 3 | # -- recommended but optional: 4 | python_files = tests.py test_*.py *_tests.py -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | recursive-include todo/data * 4 | recursive-include todo/static * 5 | recursive-include todo/templates * 6 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security 2 | 3 | If you find what you believe is a security issue with django-todo, please send a detailed report to django_todo_security_1213@birdhouse.org before publicizing. We thank you for your discretion. 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # tools, IDEs, build folders 2 | /.coverage/ 3 | /.eggs/ 4 | /.idea/ 5 | /build/ 6 | /dist/ 7 | /docs/build/ 8 | /*.egg-info/ 9 | settings.json 10 | 11 | # Django and Python 12 | *.py[cod] 13 | .pytest_cache/* 14 | .mypy_cache 15 | .venv 16 | -------------------------------------------------------------------------------- /todo/templates/todo/base.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load static %} 3 | 4 | {% block extrahead %} 5 | 6 | 7 | {% endblock extrahead %} 8 | -------------------------------------------------------------------------------- /todo/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | try: 3 | # if django is not installed, 4 | # skips check because it blocks automated installs 5 | import django 6 | from . import check 7 | except ModuleNotFoundError: 8 | # this can happen during install time, if django is not installed yet! 9 | pass 10 | -------------------------------------------------------------------------------- /todo/tests/data/csv_import_data.csv: -------------------------------------------------------------------------------- 1 | Title,Group,Task List,Created By,Created Date,Due Date,Completed,Assigned To,Note,Priority 2 | Make dinner,Workgroup One,Zip,u1,,2019-06-14,No,u1,This is note one,3 3 | Bake bread,Workgroup One,Zip,u1,2012-03-14,,Yes,,, 4 | Bring dessert,Workgroup Two,Zap,u2,2015-06-248,,,,This is note two,77 -------------------------------------------------------------------------------- /todo/mail/producers/__init__.py: -------------------------------------------------------------------------------- 1 | def imap_producer(**kwargs): 2 | def imap_producer_factory(): 3 | # the import needs to be delayed until call to enable 4 | # using the wrapper in the django settings 5 | from .imap import imap_producer 6 | 7 | return imap_producer(**kwargs) 8 | 9 | return imap_producer_factory 10 | -------------------------------------------------------------------------------- /todo/mail/consumers/__init__.py: -------------------------------------------------------------------------------- 1 | def tracker_consumer(**kwargs): 2 | def tracker_factory(producer): 3 | # the import needs to be delayed until call to enable 4 | # using the wrapper in the django settings 5 | from .tracker import tracker_consumer 6 | 7 | return tracker_consumer(producer, **kwargs) 8 | 9 | return tracker_factory 10 | -------------------------------------------------------------------------------- /todo/data/import_example.csv: -------------------------------------------------------------------------------- 1 | Title,Group,Task List,Created By,Created Date,Due Date,Completed,Assigned To,Note,Priority 2 | Make dinner,Scuba Divers,Web project,shacker,,2019-06-14,No,,Please check with mgmt first,3 3 | Bake bread,Scuba Divers,Example List,mr_random,2012-03-14,,Yes,,, 4 | Bring dessert,Scuba Divers,Web project,user1,2015-06-248,,,user1,Every generation throws a hero up the pop charts,77 -------------------------------------------------------------------------------- /base_urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import include, path 2 | 3 | """ 4 | This urlconf exists so we can run tests without an actual Django project 5 | (Django expects ROOT_URLCONF to exist.) This helps the tests remain isolated. 6 | For your project, ignore this file and add 7 | 8 | `path('lists/', include('todo.urls')),` 9 | 10 | to your site's urlconf. 11 | """ 12 | 13 | urlpatterns = [path("lists/", include("todo.urls"))] 14 | -------------------------------------------------------------------------------- /todo/migrations/0006_rename_item_model.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.3 on 2018-03-28 22:40 2 | 3 | from django.conf import settings 4 | from django.db import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | atomic = False 9 | dependencies = [ 10 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 11 | ("todo", "0005_auto_20180212_2325"), 12 | ] 13 | 14 | operations = [migrations.RenameModel(old_name="Item", new_name="Task")] 15 | -------------------------------------------------------------------------------- /todo/templates/todo/email/newcomment_body.txt: -------------------------------------------------------------------------------- 1 | A new task comment has been added. 2 | 3 | Task: {{ task.title }} 4 | Commenter: {{ user.first_name }} {{ user.last_name }} 5 | 6 | Comment: 7 | {% autoescape off %} 8 | {{ body }} 9 | {% endautoescape %} 10 | 11 | Task details/comments: 12 | https://{{ site }}{% url 'todo:task_detail' task.id %} 13 | 14 | List {{ task.task_list.name }}: 15 | https://{{ site }}{% url 'todo:list_detail' task.task_list.id task.task_list.slug %} 16 | 17 | -------------------------------------------------------------------------------- /todo/templates/todo/include/toggle_delete.html: -------------------------------------------------------------------------------- 1 | 2 | {% if list_slug != "mine" %} 3 | {% if view_completed %} 4 | View incomplete tasks 5 | 6 | {% else %} 7 | View completed tasks 8 | 9 | {% endif %} 10 | 11 | Delete this list 12 | {% endif %} 13 | -------------------------------------------------------------------------------- /todo/migrations/0007_auto_update_created_date.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.4 on 2018-04-05 00:24 2 | 3 | from django.db import migrations, models 4 | import django.utils.timezone 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [("todo", "0006_rename_item_model")] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="task", 14 | name="created_date", 15 | field=models.DateField(blank=True, default=django.utils.timezone.now, null=True), 16 | ) 17 | ] 18 | -------------------------------------------------------------------------------- /todo/features.py: -------------------------------------------------------------------------------- 1 | # The integrated mail queue functionality can enable advanced functionality if 2 | # django-autocomplete-light is installed and configured. We can use this module 3 | # to check for other installed dependencies in the future. 4 | 5 | HAS_AUTOCOMPLETE = True 6 | try: 7 | import dal 8 | except ImportError: 9 | HAS_AUTOCOMPLETE = False 10 | 11 | HAS_TASK_MERGE = False 12 | if HAS_AUTOCOMPLETE: 13 | import dal.autocomplete 14 | 15 | if getattr(dal.autocomplete, "Select2QuerySetView", None) is not None: 16 | HAS_TASK_MERGE = True 17 | -------------------------------------------------------------------------------- /todo/templates/todo/email/assigned_body.txt: -------------------------------------------------------------------------------- 1 | {{ task.assigned_to.first_name }} - 2 | 3 | A new task on the list {{ task.task_list.name }} has been assigned to you by {{ task.created_by.get_full_name }}: 4 | 5 | {{ task.title }} 6 | 7 | {% if task.note %} 8 | {% autoescape off %} 9 | Note: {{ task.note }} 10 | {% endautoescape %} 11 | {% endif %} 12 | 13 | Task details/comments: 14 | http://{{ site }}{% url 'todo:task_detail' task.id %} 15 | 16 | List {{ task.task_list.name }}: 17 | http://{{ site }}{% url 'todo:list_detail' task.task_list.id task.task_list.slug %} 18 | -------------------------------------------------------------------------------- /todo/migrations/0002_auto_20150614_2339.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [("todo", "0001_initial")] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="item", name="created_date", field=models.DateField(auto_now=True) 14 | ), 15 | migrations.AlterField( 16 | model_name="item", name="priority", field=models.PositiveIntegerField() 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /todo/migrations/0005_auto_20180212_2325.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.2 on 2018-02-12 23:25 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [("todo", "0004_rename_list_tasklist")] 9 | 10 | operations = [ 11 | migrations.AlterModelOptions( 12 | name="tasklist", options={"ordering": ["name"], "verbose_name_plural": "Task Lists"} 13 | ), 14 | migrations.AlterField( 15 | model_name="item", name="completed", field=models.BooleanField(default=False) 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /todo/migrations/0009_priority_optional.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.7 on 2019-03-18 23:14 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [("todo", "0008_mail_tracker")] 9 | 10 | operations = [ 11 | migrations.AlterModelOptions( 12 | name="task", options={"ordering": ["priority", "created_date"]} 13 | ), 14 | migrations.AlterField( 15 | model_name="task", 16 | name="priority", 17 | field=models.PositiveIntegerField(blank=True, null=True), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /todo/check.py: -------------------------------------------------------------------------------- 1 | from django.core.checks import Error, register 2 | 3 | # the sole purpose of this warning is to prevent people who have 4 | # django-autocomplete-light installed but not configured to start the app 5 | @register() 6 | def dal_check(app_configs, **kwargs): 7 | from django.conf import settings 8 | from todo.features import HAS_AUTOCOMPLETE 9 | 10 | if not HAS_AUTOCOMPLETE: 11 | return [] 12 | 13 | errors = [] 14 | missing_apps = {"dal", "dal_select2"} - set(settings.INSTALLED_APPS) 15 | for missing_app in missing_apps: 16 | errors.append(Error("{} needs to be in INSTALLED_APPS".format(missing_app))) 17 | return errors 18 | -------------------------------------------------------------------------------- /todo/migrations/0011_add_related_name_to_created_by.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.8 on 2019-07-24 11:30 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('todo', '0010_attachment'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='task', 17 | name='created_by', 18 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='todo_created_by', to=settings.AUTH_USER_MODEL), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /todo/migrations/0012_add_related_name_to_comments.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.24 on 2021-09-22 22:05 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('todo', '0011_add_related_name_to_created_by'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='comment', 17 | name='author', 18 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='todo_comments', to=settings.AUTH_USER_MODEL), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /todo/views/__init__.py: -------------------------------------------------------------------------------- 1 | from todo.views.add_list import add_list # noqa: F401 2 | from todo.views.del_list import del_list # noqa: F401 3 | from todo.views.delete_task import delete_task # noqa: F401 4 | from todo.views.external_add import external_add # noqa: F401 5 | from todo.views.import_csv import import_csv # noqa: F401 6 | from todo.views.list_detail import list_detail # noqa: F401 7 | from todo.views.list_lists import list_lists # noqa: F401 8 | from todo.views.remove_attachment import remove_attachment # noqa: F401 9 | from todo.views.reorder_tasks import reorder_tasks # noqa: F401 10 | from todo.views.search import search # noqa: F401 11 | from todo.views.task_detail import task_detail # noqa: F401 12 | from todo.views.toggle_done import toggle_done # noqa: F401 13 | -------------------------------------------------------------------------------- /todo/templates/todo/add_list.html: -------------------------------------------------------------------------------- 1 | {% extends "todo/base.html" %} 2 | {% block page_heading %}{% endblock %} 3 | {% block title %}Add Todo List{% endblock %} 4 | {% block content %} 5 | 6 |

Add a list:

7 | 8 |
9 | {% csrf_token %} 10 | 11 |
12 | 13 | 14 | The full display name for this list. 15 |
16 |
17 | 18 | {{form.group}} 19 |
20 | 21 | 22 |
23 | {% endblock %} -------------------------------------------------------------------------------- /todo/migrations/0003_assignee_optional.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.5 on 2016-04-09 11:11 3 | from __future__ import unicode_literals 4 | 5 | from django.conf import settings 6 | from django.db import migrations, models 7 | import django.db.models.deletion 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | dependencies = [("todo", "0002_auto_20150614_2339")] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name="item", 17 | name="assigned_to", 18 | field=models.ForeignKey( 19 | blank=True, 20 | null=True, 21 | on_delete=django.db.models.deletion.CASCADE, 22 | related_name="todo_assigned_to", 23 | to=settings.AUTH_USER_MODEL, 24 | ), 25 | ) 26 | ] 27 | -------------------------------------------------------------------------------- /todo/defaults.py: -------------------------------------------------------------------------------- 1 | # If a documented django-todo option is NOT configured in settings, use these values. 2 | from django.conf import settings 3 | 4 | hash = { 5 | "TODO_ALLOW_FILE_ATTACHMENTS": True, 6 | "TODO_COMMENT_CLASSES": [], 7 | "TODO_DEFAULT_ASSIGNEE": None, 8 | "TODO_LIMIT_FILE_ATTACHMENTS": [".jpg", ".gif", ".png", ".csv", ".pdf", ".zip"], 9 | "TODO_MAXIMUM_ATTACHMENT_SIZE": 5000000, 10 | "TODO_PUBLIC_SUBMIT_REDIRECT": "/", 11 | "TODO_STAFF_ONLY": True, 12 | } 13 | 14 | # These intentionally have no defaults (user MUST set a value if their features are used): 15 | # TODO_DEFAULT_LIST_SLUG 16 | # TODO_MAIL_BACKENDS 17 | # TODO_MAIL_TRACKERS 18 | 19 | 20 | def defaults(key: str): 21 | """Try to get a setting from project settings. 22 | If empty or doesn't exist, fall back to a value from defaults hash.""" 23 | 24 | if hasattr(settings, key): 25 | val = getattr(settings, key) 26 | else: 27 | val = hash.get(key) 28 | return val 29 | -------------------------------------------------------------------------------- /todo/mail/delivery.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | 3 | 4 | def _declare_backend(backend_path): 5 | backend_path = backend_path.split(".") 6 | backend_module_name = ".".join(backend_path[:-1]) 7 | class_name = backend_path[-1] 8 | 9 | def backend(*args, headers={}, from_address=None, **kwargs): 10 | def _backend(): 11 | backend_module = importlib.import_module(backend_module_name) 12 | backend = getattr(backend_module, class_name) 13 | return backend(*args, **kwargs) 14 | 15 | if from_address is None: 16 | raise ValueError("missing from_address") 17 | 18 | _backend.from_address = from_address 19 | _backend.headers = headers 20 | return _backend 21 | 22 | return backend 23 | 24 | 25 | smtp_backend = _declare_backend("django.core.mail.backends.smtp.EmailBackend") 26 | console_backend = _declare_backend("django.core.mail.backends.console.EmailBackend") 27 | locmem_backend = _declare_backend("django.core.mail.backends.locmem.EmailBackend") 28 | -------------------------------------------------------------------------------- /todo/templates/todo/search_results.html: -------------------------------------------------------------------------------- 1 | {% extends "todo/base.html" %} 2 | 3 | {% block title %}Search results{% endblock %} 4 | {% block content_title %}

Search

{% endblock %} 5 | 6 | {% block content %} 7 | {% if found_tasks %} 8 |

{{found_tasks.count}} search results for term: "{{ query_string }}"

9 |
10 | {% for f in found_tasks %} 11 |

12 | 13 | {{ f.title }} 14 | 15 |
16 | 17 | In list: 18 | 19 | {{ f.task_list.name }} 20 | 21 |
Assigned to: {% if f.assigned_to %}{{ f.assigned_to }}{% else %}Anyone{% endif %} 22 |
Complete: {{ f.completed|yesno:"Yes,No" }} 23 |
24 |

25 | {% endfor %} 26 |
27 | {% else %} 28 |

No results to show, sorry.

29 | {% endif %} 30 | {% endblock %} 31 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = django-todo 3 | version = 2.5.0 4 | description = A multi-user, multi-group task management and assignment system for Django. 5 | long_description = file: README.md 6 | long_description_content_type = text/markdown 7 | url = http://django-todo.org 8 | author = Scot Hacker 9 | author_email = shacker@birdhouse.org 10 | license = BSD-3-Clause 11 | classifiers = 12 | Environment :: Web Environment 13 | Framework :: Django 14 | Framework :: Django :: 4.0 15 | Intended Audience :: Developers 16 | License :: OSI Approved :: BSD License 17 | Operating System :: OS Independent 18 | Programming Language :: Python 19 | Programming Language :: Python :: 3 20 | Programming Language :: Python :: 3 :: Only 21 | Programming Language :: Python :: 3.9 22 | Programming Language :: Python :: 3.10 23 | Programming Language :: Python :: 3.11 24 | 25 | [options] 26 | include_package_data = true 27 | packages = find: 28 | python_requires = >=3.9 29 | install_requires = 30 | Django >= 4.0 31 | factory-boy 32 | titlecase 33 | bleach 34 | django-autocomplete-light 35 | html2text 36 | -------------------------------------------------------------------------------- /todo/templates/todo/list_lists.html: -------------------------------------------------------------------------------- 1 | {% extends "todo/base.html" %} 2 | 3 | {% block title %}{{ list_title }} Todo Lists{% endblock %} 4 | 5 | {% block content %} 6 |

Todo Lists

7 | 8 |

{{ task_count }} tasks in {{ list_count }} list{{ list_count|pluralize }}

9 | 10 | {% regroup lists by group as section_list %} 11 | {% for group in section_list %} 12 |

Group: {{ group.grouper }}

13 | 21 | {% endfor %} 22 | 23 |
24 | 25 | {% if user.is_staff %} 26 | Create new todo list 27 | {% else %} 28 | If you were staff, you could create a new list 29 | {% endif %} 30 | 31 |
32 | 33 | {% endblock %} -------------------------------------------------------------------------------- /todo/views/task_autocomplete.py: -------------------------------------------------------------------------------- 1 | from dal import autocomplete 2 | from django.contrib.auth.decorators import login_required 3 | from django.core.exceptions import PermissionDenied 4 | from django.shortcuts import get_object_or_404 5 | from django.utils.decorators import method_decorator 6 | from todo.models import Task 7 | from todo.utils import user_can_read_task 8 | 9 | 10 | class TaskAutocomplete(autocomplete.Select2QuerySetView): 11 | @method_decorator(login_required) 12 | def dispatch(self, request, task_id, *args, **kwargs): 13 | self.task = get_object_or_404(Task, pk=task_id) 14 | if not user_can_read_task(self.task, request.user): 15 | raise PermissionDenied 16 | 17 | return super().dispatch(request, task_id, *args, **kwargs) 18 | 19 | def get_queryset(self): 20 | # Don't forget to filter out results depending on the visitor ! 21 | if not self.request.user.is_authenticated: 22 | return Task.objects.none() 23 | 24 | qs = Task.objects.filter(task_list=self.task.task_list).exclude(pk=self.task.pk) 25 | 26 | if self.q: 27 | qs = qs.filter(title__istartswith=self.q) 28 | 29 | return qs 30 | -------------------------------------------------------------------------------- /todo/templates/todo/del_list.html: -------------------------------------------------------------------------------- 1 | {% extends "todo/base.html" %} 2 | {% block title %}Delete list{% endblock %} 3 | 4 | {% block content %} 5 | {% if user.is_staff %} 6 |

Delete entire list: {{ task_list.name }} ?

7 | 8 |

Category tally:

9 | 10 | 17 | 18 |

... all of which will be irretrievably 19 | blown away. Are you sure you want to do that?

20 | 21 |
22 | {% csrf_token %} 23 | 24 |

25 | Return to list: {{ task_list.name }} 26 | 27 |

28 |
29 | 30 | 31 | {% else %} 32 |

Sorry, you don't have permission to delete lists. Please contact your group administrator.

33 | {% endif %} {% endblock %} -------------------------------------------------------------------------------- /todo/views/import_csv.py: -------------------------------------------------------------------------------- 1 | from django.contrib import messages 2 | from django.contrib.auth.decorators import login_required, user_passes_test 3 | from django.http import HttpResponse 4 | from django.shortcuts import redirect, render, reverse 5 | 6 | from todo.operations.csv_importer import CSVImporter 7 | from todo.utils import staff_check 8 | 9 | 10 | @login_required 11 | @user_passes_test(staff_check) 12 | def import_csv(request) -> HttpResponse: 13 | """Import a specifically formatted CSV into stored tasks. 14 | """ 15 | 16 | ctx = {"results": None} 17 | 18 | if request.method == "POST": 19 | filepath = request.FILES.get("csvfile") 20 | 21 | if not filepath: 22 | messages.error(request, "You must supply a CSV file to import.") 23 | return redirect(reverse("todo:import_csv")) 24 | 25 | importer = CSVImporter() 26 | results = importer.upsert(filepath) 27 | 28 | if results: 29 | ctx["results"] = results 30 | else: 31 | messages.error(request, "Could not parse provided CSV file.") 32 | return redirect(reverse("todo:import_csv")) 33 | 34 | return render(request, "todo/import_csv.html", context=ctx) 35 | -------------------------------------------------------------------------------- /todo/management/commands/mail_worker.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import socket 3 | import sys 4 | 5 | from django.core.management.base import BaseCommand 6 | from django.conf import settings 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | DEFAULT_IMAP_TIMEOUT = 20 12 | 13 | 14 | class Command(BaseCommand): 15 | help = "Starts a mail worker" 16 | 17 | def add_arguments(self, parser): 18 | parser.add_argument("--imap_timeout", type=int, default=30) 19 | parser.add_argument("worker_name") 20 | 21 | def handle(self, *args, **options): 22 | if not hasattr(settings, "TODO_MAIL_TRACKERS"): 23 | logger.error("missing TODO_MAIL_TRACKERS setting") 24 | sys.exit(1) 25 | 26 | worker_name = options["worker_name"] 27 | tracker = settings.TODO_MAIL_TRACKERS.get(worker_name, None) 28 | if tracker is None: 29 | logger.error("couldn't find configuration for %r in TODO_MAIL_TRACKERS", worker_name) 30 | sys.exit(1) 31 | 32 | # set the default socket timeout (imaplib doesn't enable configuring it) 33 | timeout = options["imap_timeout"] 34 | if timeout: 35 | socket.setdefaulttimeout(timeout) 36 | 37 | # run the mail polling loop 38 | producer = tracker["producer"] 39 | consumer = tracker["consumer"] 40 | 41 | consumer(producer()) 42 | -------------------------------------------------------------------------------- /todo/views/reorder_tasks.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.decorators import login_required, user_passes_test 2 | from django.http import HttpResponse 3 | from django.views.decorators.csrf import csrf_exempt 4 | 5 | from todo.models import Task 6 | from todo.utils import staff_check 7 | 8 | 9 | @csrf_exempt 10 | @login_required 11 | @user_passes_test(staff_check) 12 | def reorder_tasks(request) -> HttpResponse: 13 | """Handle task re-ordering (priorities) from JQuery drag/drop in list_detail.html 14 | """ 15 | newtasklist = request.POST.getlist("tasktable[]") 16 | if newtasklist: 17 | # First task in received list is always empty - remove it 18 | del newtasklist[0] 19 | 20 | # Re-prioritize each task in list 21 | i = 1 22 | for id in newtasklist: 23 | try: 24 | task = Task.objects.get(pk=id) 25 | task.priority = i 26 | task.save() 27 | i += 1 28 | except Task.DoesNotExist: 29 | # Can occur if task is deleted behind the scenes during re-ordering. 30 | # Not easy to remove it from the UI without page refresh, but prevent crash. 31 | pass 32 | 33 | # All views must return an httpresponse of some kind ... without this we get 34 | # error 500s in the log even though things look peachy in the browser. 35 | return HttpResponse(status=201) 36 | -------------------------------------------------------------------------------- /todo/views/delete_task.py: -------------------------------------------------------------------------------- 1 | from django.contrib import messages 2 | from django.contrib.auth.decorators import login_required, user_passes_test 3 | from django.core.exceptions import PermissionDenied 4 | from django.http import HttpResponse 5 | from django.shortcuts import get_object_or_404, redirect 6 | from django.urls import reverse 7 | 8 | from todo.models import Task 9 | from todo.utils import staff_check 10 | 11 | 12 | @login_required 13 | @user_passes_test(staff_check) 14 | def delete_task(request, task_id: int) -> HttpResponse: 15 | """Delete specified task. 16 | Redirect to the list from which the task came. 17 | """ 18 | 19 | if request.method == "POST": 20 | task = get_object_or_404(Task, pk=task_id) 21 | 22 | redir_url = reverse( 23 | "todo:list_detail", 24 | kwargs={"list_id": task.task_list.id, "list_slug": task.task_list.slug}, 25 | ) 26 | 27 | # Permissions 28 | if not ( 29 | (task.created_by == request.user) 30 | or (request.user.is_superuser) 31 | or (task.assigned_to == request.user) 32 | or (task.task_list.group in request.user.groups.all()) 33 | ): 34 | raise PermissionDenied 35 | 36 | task.delete() 37 | 38 | messages.success(request, "Task '{}' has been deleted".format(task.title)) 39 | return redirect(redir_url) 40 | 41 | else: 42 | raise PermissionDenied 43 | -------------------------------------------------------------------------------- /todo/views/remove_attachment.py: -------------------------------------------------------------------------------- 1 | from django.contrib import messages 2 | from django.contrib.auth.decorators import login_required 3 | from django.core.exceptions import PermissionDenied 4 | from django.http import HttpResponse 5 | from django.shortcuts import get_object_or_404, redirect 6 | from django.urls import reverse 7 | 8 | from todo.models import Attachment 9 | from todo.utils import remove_attachment_file 10 | 11 | 12 | @login_required 13 | def remove_attachment(request, attachment_id: int) -> HttpResponse: 14 | """Delete a previously posted attachment object and its corresponding file 15 | from the filesystem, permissions allowing. 16 | """ 17 | 18 | if request.method == "POST": 19 | attachment = get_object_or_404(Attachment, pk=attachment_id) 20 | 21 | redir_url = reverse("todo:task_detail", kwargs={"task_id": attachment.task.id}) 22 | 23 | # Permissions 24 | if not ( 25 | attachment.task.task_list.group in request.user.groups.all() 26 | or request.user.is_superuser 27 | ): 28 | raise PermissionDenied 29 | 30 | if remove_attachment_file(attachment.id): 31 | messages.success(request, f"Attachment {attachment.id} removed.") 32 | else: 33 | messages.error( 34 | request, f"Sorry, there was a problem deleting attachment {attachment.id}." 35 | ) 36 | 37 | return redirect(redir_url) 38 | 39 | else: 40 | raise PermissionDenied 41 | -------------------------------------------------------------------------------- /todo/views/toggle_done.py: -------------------------------------------------------------------------------- 1 | from django.contrib import messages 2 | from django.contrib.auth.decorators import login_required, user_passes_test 3 | from django.core.exceptions import PermissionDenied 4 | from django.http import HttpResponse 5 | from django.shortcuts import get_object_or_404, redirect 6 | from django.urls import reverse 7 | 8 | from todo.models import Task 9 | from todo.utils import toggle_task_completed 10 | from todo.utils import staff_check 11 | 12 | 13 | @login_required 14 | @user_passes_test(staff_check) 15 | def toggle_done(request, task_id: int) -> HttpResponse: 16 | """Toggle the completed status of a task from done to undone, or vice versa. 17 | Redirect to the list from which the task came. 18 | """ 19 | 20 | if request.method == "POST": 21 | task = get_object_or_404(Task, pk=task_id) 22 | 23 | redir_url = reverse( 24 | "todo:list_detail", 25 | kwargs={"list_id": task.task_list.id, "list_slug": task.task_list.slug}, 26 | ) 27 | 28 | # Permissions 29 | if not ( 30 | (task.created_by == request.user) 31 | or (request.user.is_superuser) 32 | or (task.assigned_to == request.user) 33 | or (task.task_list.group in request.user.groups.all()) 34 | ): 35 | raise PermissionDenied 36 | 37 | toggle_task_completed(task.id) 38 | messages.success(request, "Task status changed for '{}'".format(task.title)) 39 | 40 | return redirect(redir_url) 41 | 42 | else: 43 | raise PermissionDenied 44 | -------------------------------------------------------------------------------- /todo/views/search.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.decorators import login_required, user_passes_test 2 | from django.db.models import Q 3 | from django.http import HttpResponse 4 | from django.shortcuts import render 5 | 6 | from todo.models import Task 7 | from todo.utils import staff_check 8 | 9 | 10 | @login_required 11 | @user_passes_test(staff_check) 12 | def search(request) -> HttpResponse: 13 | """Search for tasks user has permission to see. 14 | """ 15 | 16 | query_string = "" 17 | 18 | if request.GET: 19 | 20 | found_tasks = None 21 | if ("q" in request.GET) and request.GET["q"].strip(): 22 | query_string = request.GET["q"] 23 | 24 | found_tasks = Task.objects.filter( 25 | Q(title__icontains=query_string) | Q(note__icontains=query_string) 26 | ) 27 | else: 28 | # What if they selected the "completed" toggle but didn't enter a query string? 29 | # We still need found_tasks in a queryset so it can be "excluded" below. 30 | found_tasks = Task.objects.all() 31 | 32 | if "inc_complete" in request.GET: 33 | found_tasks = found_tasks.exclude(completed=True) 34 | 35 | else: 36 | found_tasks = None 37 | 38 | # Only include tasks that are in groups of which this user is a member: 39 | if not request.user.is_superuser: 40 | found_tasks = found_tasks.filter(task_list__group__in=request.user.groups.all()) 41 | 42 | context = {"query_string": query_string, "found_tasks": found_tasks} 43 | return render(request, "todo/search_results.html", context) 44 | -------------------------------------------------------------------------------- /todo/migrations/0010_attachment.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2 on 2019-04-06 16:28 2 | 3 | import datetime 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | import todo.models 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ("todo", "0009_priority_optional"), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name="Attachment", 20 | fields=[ 21 | ( 22 | "id", 23 | models.AutoField( 24 | auto_created=True, primary_key=True, serialize=False, verbose_name="ID" 25 | ), 26 | ), 27 | ("timestamp", models.DateTimeField(default=datetime.datetime.now)), 28 | ( 29 | "file", 30 | models.FileField( 31 | max_length=255, upload_to=todo.models.get_attachment_upload_dir 32 | ), 33 | ), 34 | ( 35 | "added_by", 36 | models.ForeignKey( 37 | on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL 38 | ), 39 | ), 40 | ( 41 | "task", 42 | models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="todo.Task"), 43 | ), 44 | ], 45 | ) 46 | ] 47 | -------------------------------------------------------------------------------- /todo/migrations/0008_mail_tracker.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.7 on 2019-03-24 22:50 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [("todo", "0007_auto_update_created_date")] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="comment", 15 | name="email_from", 16 | field=models.CharField(blank=True, max_length=320, null=True), 17 | ), 18 | migrations.AddField( 19 | model_name="comment", 20 | name="email_message_id", 21 | field=models.CharField(blank=True, max_length=255, null=True), 22 | ), 23 | migrations.AlterField( 24 | model_name="comment", 25 | name="author", 26 | field=models.ForeignKey( 27 | blank=True, 28 | null=True, 29 | on_delete=django.db.models.deletion.CASCADE, 30 | to=settings.AUTH_USER_MODEL, 31 | ), 32 | ), 33 | migrations.AlterField( 34 | model_name="task", 35 | name="created_by", 36 | field=models.ForeignKey( 37 | null=True, 38 | on_delete=django.db.models.deletion.CASCADE, 39 | related_name="todo_created_by", 40 | to=settings.AUTH_USER_MODEL, 41 | ), 42 | ), 43 | migrations.AlterUniqueTogether( 44 | name="comment", unique_together={("task", "email_message_id")} 45 | ), 46 | ] 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010, Scot Hacker, Birdhouse Arts and individual contributors. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of Birdhouse Arts nor the names of its contributors 15 | may be used to endorse or promote products derived from this software 16 | without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /todo/migrations/0004_rename_list_tasklist.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.2 on 2018-02-09 23:15 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ("auth", "0009_alter_user_last_name_max_length"), 11 | ("todo", "0003_assignee_optional"), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name="TaskList", 17 | fields=[ 18 | ( 19 | "id", 20 | models.AutoField( 21 | auto_created=True, primary_key=True, serialize=False, verbose_name="ID" 22 | ), 23 | ), 24 | ("name", models.CharField(max_length=60)), 25 | ("slug", models.SlugField(default="")), 26 | ( 27 | "group", 28 | models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="auth.Group"), 29 | ), 30 | ], 31 | options={"verbose_name_plural": "Lists", "ordering": ["name"]}, 32 | ), 33 | migrations.AlterUniqueTogether(name="list", unique_together=set()), 34 | migrations.RemoveField(model_name="list", name="group"), 35 | migrations.RemoveField(model_name="item", name="list"), 36 | migrations.DeleteModel(name="List"), 37 | migrations.AddField( 38 | model_name="item", 39 | name="task_list", 40 | field=models.ForeignKey( 41 | null=True, on_delete=django.db.models.deletion.CASCADE, to="todo.TaskList" 42 | ), 43 | ), 44 | migrations.AlterUniqueTogether(name="tasklist", unique_together={("group", "slug")}), 45 | ] 46 | -------------------------------------------------------------------------------- /todo/templates/todo/add_task_external.html: -------------------------------------------------------------------------------- 1 | {% extends "todo/base.html" %} 2 | {% block page_heading %}{% endblock %} 3 | {% block title %}File Ticket{% endblock %} 4 | 5 | {% block content %} 6 | 7 |

{{ task }}

8 | 9 |
10 | {% csrf_token %} 11 | 12 |

File Trouble Ticket

13 |

14 | Have a support issue? Use this form to report the difficulty - we'll get right back to you. 15 |

16 | 17 | {% if form.errors %} 18 | {% for error in form.errors %} 19 | 24 | {% endfor %} 25 | {% endif %} 26 | 27 | 28 | {% csrf_token %} 29 |
30 | 31 | 33 |
34 |
35 | 36 | 38 | 39 | Describe the issue. Please include essential details. 40 | 41 |
42 | 43 | 44 |

45 |
46 | 47 | {% endblock %} 48 | -------------------------------------------------------------------------------- /todo/views/del_list.py: -------------------------------------------------------------------------------- 1 | from django.contrib import messages 2 | from django.contrib.auth.decorators import login_required, user_passes_test 3 | from django.core.exceptions import PermissionDenied 4 | from django.http import HttpResponse 5 | from django.shortcuts import get_object_or_404, redirect, render 6 | 7 | from todo.models import Task, TaskList 8 | from todo.utils import staff_check 9 | 10 | 11 | @login_required 12 | @user_passes_test(staff_check) 13 | def del_list(request, list_id: int, list_slug: str) -> HttpResponse: 14 | """Delete an entire list. Only staff members should be allowed to access this view. 15 | """ 16 | task_list = get_object_or_404(TaskList, id=list_id) 17 | 18 | # Ensure user has permission to delete list. Get the group this list belongs to, 19 | # and check whether current user is a member of that group AND a staffer. 20 | if task_list.group not in request.user.groups.all(): 21 | raise PermissionDenied 22 | if not request.user.is_staff: 23 | raise PermissionDenied 24 | 25 | if request.method == "POST": 26 | TaskList.objects.get(id=task_list.id).delete() 27 | messages.success(request, "{list_name} is gone.".format(list_name=task_list.name)) 28 | return redirect("todo:lists") 29 | else: 30 | task_count_done = Task.objects.filter(task_list=task_list.id, completed=True).count() 31 | task_count_undone = Task.objects.filter(task_list=task_list.id, completed=False).count() 32 | task_count_total = Task.objects.filter(task_list=task_list.id).count() 33 | 34 | context = { 35 | "task_list": task_list, 36 | "task_count_done": task_count_done, 37 | "task_count_undone": task_count_undone, 38 | "task_count_total": task_count_total, 39 | } 40 | 41 | return render(request, "todo/del_list.html", context) 42 | -------------------------------------------------------------------------------- /todo/views/list_lists.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from django.contrib import messages 4 | from django.contrib.auth.decorators import login_required, user_passes_test 5 | from django.http import HttpResponse 6 | from django.shortcuts import render 7 | 8 | from todo.forms import SearchForm 9 | from todo.models import Task, TaskList 10 | from todo.utils import staff_check 11 | 12 | 13 | @login_required 14 | @user_passes_test(staff_check) 15 | def list_lists(request) -> HttpResponse: 16 | """Homepage view - list of lists a user can view, and ability to add a list. 17 | """ 18 | 19 | thedate = datetime.datetime.now() 20 | searchform = SearchForm(auto_id=False) 21 | 22 | # Make sure user belongs to at least one group. 23 | if not request.user.groups.all().exists(): 24 | messages.warning( 25 | request, 26 | "You do not yet belong to any groups. Ask your administrator to add you to one.", 27 | ) 28 | 29 | # Superusers see all lists 30 | lists = TaskList.objects.all().order_by("group__name", "name") 31 | if not request.user.is_superuser: 32 | lists = lists.filter(group__in=request.user.groups.all()) 33 | 34 | list_count = lists.count() 35 | 36 | # superusers see all lists, so count shouldn't filter by just lists the admin belongs to 37 | if request.user.is_superuser: 38 | task_count = Task.objects.filter(completed=0).count() 39 | else: 40 | task_count = ( 41 | Task.objects.filter(completed=0) 42 | .filter(task_list__group__in=request.user.groups.all()) 43 | .count() 44 | ) 45 | 46 | context = { 47 | "lists": lists, 48 | "thedate": thedate, 49 | "searchform": searchform, 50 | "list_count": list_count, 51 | "task_count": task_count, 52 | } 53 | 54 | return render(request, "todo/list_lists.html", context) 55 | -------------------------------------------------------------------------------- /todo/admin.py: -------------------------------------------------------------------------------- 1 | import csv 2 | import datetime 3 | 4 | from django.contrib import admin 5 | from django.http import HttpResponse 6 | 7 | from todo.models import Attachment, Comment, Task, TaskList 8 | 9 | 10 | def export_to_csv(modeladmin, request, queryset): 11 | opts = modeladmin.model._meta 12 | content_disposition = f"attachment; filename={opts.verbose_name}.csv" 13 | response = HttpResponse(content_type="text/csv") 14 | response["Content-Disposition"] = content_disposition 15 | writer = csv.writer(response) 16 | fields = [ 17 | field for field in opts.get_fields() if not (field.many_to_many and not field.one_to_many) 18 | ] 19 | # Write a first row with header information 20 | writer.writerow([field.verbose_name for field in fields]) 21 | # Write data rows 22 | for obj in queryset: 23 | data_row = [] 24 | for field in fields: 25 | value = getattr(obj, field.name) 26 | if isinstance(value, datetime.datetime): 27 | value = value.strftime("%d/%m/%Y") 28 | data_row.append(value) 29 | writer.writerow(data_row) 30 | return response 31 | 32 | 33 | export_to_csv.short_description = "Export to CSV" 34 | 35 | 36 | class TaskAdmin(admin.ModelAdmin): 37 | list_display = ("title", "task_list", "completed", "priority", "due_date") 38 | list_filter = ("task_list",) 39 | ordering = ("priority",) 40 | search_fields = ("title",) 41 | actions = [export_to_csv] 42 | 43 | 44 | class CommentAdmin(admin.ModelAdmin): 45 | list_display = ("author", "date", "snippet") 46 | 47 | 48 | class AttachmentAdmin(admin.ModelAdmin): 49 | list_display = ("task", "added_by", "timestamp", "file") 50 | autocomplete_fields = ["added_by", "task"] 51 | 52 | 53 | admin.site.register(TaskList) 54 | admin.site.register(Comment, CommentAdmin) 55 | admin.site.register(Task, TaskAdmin) 56 | admin.site.register(Attachment, AttachmentAdmin) 57 | -------------------------------------------------------------------------------- /todo/tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from django.contrib.auth.models import Group 4 | 5 | from todo.models import Task, TaskList 6 | 7 | 8 | @pytest.fixture 9 | def todo_setup(django_user_model): 10 | # Two groups with different users, two sets of tasks. 11 | 12 | g1 = Group.objects.create(name="Workgroup One") 13 | u1 = django_user_model.objects.create_user( 14 | username="u1", password="password", email="u1@example.com", is_staff=True 15 | ) 16 | u1.groups.add(g1) 17 | tlist1 = TaskList.objects.create(group=g1, name="Zip", slug="zip") 18 | Task.objects.create(created_by=u1, title="Task 1", task_list=tlist1, priority=1) 19 | Task.objects.create(created_by=u1, title="Task 2", task_list=tlist1, priority=2, completed=True) 20 | Task.objects.create(created_by=u1, title="Task 3", task_list=tlist1, priority=3) 21 | 22 | g2 = Group.objects.create(name="Workgroup Two") 23 | u2 = django_user_model.objects.create_user( 24 | username="u2", password="password", email="u2@example.com", is_staff=True 25 | ) 26 | u2.groups.add(g2) 27 | tlist2 = TaskList.objects.create(group=g2, name="Zap", slug="zap") 28 | Task.objects.create(created_by=u2, title="Task 1", task_list=tlist2, priority=1) 29 | Task.objects.create(created_by=u2, title="Task 2", task_list=tlist2, priority=2, completed=True) 30 | Task.objects.create(created_by=u2, title="Task 3", task_list=tlist2, priority=3) 31 | 32 | # Add a third user for a test that needs two users in the same group. 33 | extra_g2_user = django_user_model.objects.create_user( 34 | username="extra_g2_user", password="password", email="extra_g2_user@example.com", is_staff=True 35 | ) 36 | extra_g2_user.groups.add(g2) 37 | 38 | 39 | @pytest.fixture() 40 | # Set up an in-memory mail server to receive test emails 41 | def email_backend_setup(settings): 42 | settings.EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend" 43 | -------------------------------------------------------------------------------- /todo/views/add_list.py: -------------------------------------------------------------------------------- 1 | from django.contrib import messages 2 | from django.contrib.auth.decorators import login_required, user_passes_test 3 | from django.core.exceptions import PermissionDenied 4 | from django.db import IntegrityError 5 | from django.http import HttpResponse 6 | from django.shortcuts import redirect, render 7 | from django.utils.text import slugify 8 | 9 | from todo.forms import AddTaskListForm 10 | from todo.utils import staff_check 11 | 12 | 13 | @login_required 14 | @user_passes_test(staff_check) 15 | def add_list(request) -> HttpResponse: 16 | """Allow users to add a new todo list to the group they're in. 17 | """ 18 | 19 | # Only staffers can add lists, regardless of TODO_STAFF_USER setting. 20 | if not request.user.is_staff: 21 | raise PermissionDenied 22 | 23 | if request.POST: 24 | form = AddTaskListForm(request.user, request.POST) 25 | if form.is_valid(): 26 | try: 27 | newlist = form.save(commit=False) 28 | newlist.slug = slugify(newlist.name, allow_unicode=True) 29 | newlist.save() 30 | messages.success(request, "A new list has been added.") 31 | return redirect("todo:lists") 32 | 33 | except IntegrityError: 34 | messages.warning( 35 | request, 36 | "There was a problem saving the new list. " 37 | "Most likely a list with the same name in the same group already exists.", 38 | ) 39 | else: 40 | if request.user.groups.all().count() == 1: 41 | # FIXME: Assuming first of user's groups here; better to prompt for group 42 | form = AddTaskListForm(request.user, initial={"group": request.user.groups.all()[0]}) 43 | else: 44 | form = AddTaskListForm(request.user) 45 | 46 | context = {"form": form} 47 | 48 | return render(request, "todo/add_list.html", context) 49 | -------------------------------------------------------------------------------- /todo/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.urls import path 3 | 4 | from todo import views 5 | from todo.features import HAS_TASK_MERGE 6 | 7 | app_name = "todo" 8 | 9 | urlpatterns = [ 10 | path("", views.list_lists, name="lists"), 11 | # View reorder_tasks is only called by JQuery for drag/drop task ordering. 12 | path("reorder_tasks/", views.reorder_tasks, name="reorder_tasks"), 13 | # Allow users to post tasks from outside django-todo (e.g. for filing tickets - see docs) 14 | path("ticket/add/", views.external_add, name="external_add"), 15 | # Three paths into `list_detail` view 16 | path("mine/", views.list_detail, {"list_slug": "mine"}, name="mine"), 17 | path( 18 | "//completed/", 19 | views.list_detail, 20 | {"view_completed": True}, 21 | name="list_detail_completed", 22 | ), 23 | path("//", views.list_detail, name="list_detail"), 24 | path("//delete/", views.del_list, name="del_list"), 25 | path("add_list/", views.add_list, name="add_list"), 26 | path("task//", views.task_detail, name="task_detail"), 27 | path( 28 | "attachment/remove//", views.remove_attachment, name="remove_attachment" 29 | ), 30 | ] 31 | 32 | if HAS_TASK_MERGE: 33 | # ensure mail tracker autocomplete is optional 34 | from todo.views.task_autocomplete import TaskAutocomplete 35 | 36 | urlpatterns.append( 37 | path( 38 | "task//autocomplete/", TaskAutocomplete.as_view(), name="task_autocomplete" 39 | ) 40 | ) 41 | 42 | urlpatterns.extend( 43 | [ 44 | path("toggle_done//", views.toggle_done, name="task_toggle_done"), 45 | path("delete//", views.delete_task, name="delete_task"), 46 | path("search/", views.search, name="search"), 47 | path("import_csv/", views.import_csv, name="import_csv"), 48 | ] 49 | ) 50 | -------------------------------------------------------------------------------- /todo/management/commands/import_csv.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from typing import Any 3 | from pathlib import Path 4 | 5 | from django.core.management.base import BaseCommand, CommandParser 6 | 7 | from todo.operations.csv_importer import CSVImporter 8 | 9 | 10 | class Command(BaseCommand): 11 | help = """Import specifically formatted CSV file containing incoming tasks to be loaded. 12 | For specfic format of inbound CSV, see data/import_example.csv. 13 | For documentation on upsert logic and required fields, see README.md. 14 | """ 15 | 16 | def add_arguments(self, parser: CommandParser) -> None: 17 | 18 | parser.add_argument( 19 | "-f", "--file", dest="file", default=None, help="File to to inbound CSV file." 20 | ) 21 | 22 | def handle(self, *args: Any, **options: Any) -> None: 23 | # Need a file to proceed 24 | if not options.get("file"): 25 | print("Sorry, we need a filename to work from.") 26 | sys.exit(1) 27 | 28 | filepath = Path(options["file"]) 29 | 30 | if not filepath.exists(): 31 | print(f"Sorry, couldn't find file: {filepath}") 32 | sys.exit(1) 33 | 34 | # Encoding "utf-8-sig" means "ignore byte order mark (BOM), which Excel inserts when saving CSVs." 35 | with filepath.open(mode="r", encoding="utf-8-sig") as fileobj: 36 | importer = CSVImporter() 37 | results = importer.upsert(fileobj, as_string_obj=True) 38 | 39 | # Report successes, failures and summaries 40 | print() 41 | if results["upserts"]: 42 | for upsert_msg in results["upserts"]: 43 | print(upsert_msg) 44 | 45 | # Stored errors has the form: 46 | # self.errors = [{3: ["Incorrect foo", "Non-existent bar"]}, {7: [...]}] 47 | if results["errors"]: 48 | for error_dict in results["errors"]: 49 | for k, error_list in error_dict.items(): 50 | print(f"\nSkipped CSV row {k}:") 51 | for msg in error_list: 52 | print(f"- {msg}") 53 | 54 | print() 55 | if results["summaries"]: 56 | for summary_msg in results["summaries"]: 57 | print(summary_msg) 58 | -------------------------------------------------------------------------------- /todo/templates/todo/import_csv.html: -------------------------------------------------------------------------------- 1 | {% extends "todo/base.html" %} 2 | {% load static %} 3 | 4 | {% block title %}Import CSV{% endblock %} 5 | 6 | {% block content %} 7 |

8 | Import CSV 9 |

10 | 11 |

12 | Batch-import tasks by uploading a specifically-formatted CSV. 13 | See documentation for formatting rules. 14 | Successs and failures will be reported here. 15 |

16 | 17 | {% if results %} 18 |
19 |
20 | Results of CSV upload 21 |
22 |
23 | 24 | {% if results.summaries %} 25 |

26 | Summary: 27 |

28 |
    29 | {% for line in results.summaries %} 30 |
  • {{ line }}
  • 31 | {% endfor %} 32 |
33 | {% endif %} 34 | 35 | {% if results.upserts %} 36 |

37 | Upserts (tasks created or updated): 38 |

39 |
    40 | {% for line in results.upserts %} 41 |
  • {{ line }}
  • 42 | {% endfor %} 43 |
44 | {% endif %} 45 | 46 | {% if results.errors %} 47 |

48 | Errors (tasks NOT created or updated): 49 |

50 |
    51 | {% for error_row in results.errors %} 52 | {% for k, error_list in error_row.items %} 53 |
  • CSV row {{ k }}
  • 54 |
      55 | {% for err in error_list %} 56 |
    • {{ err }}
    • 57 | {% endfor %} 58 |
    59 | {% endfor %} 60 | {% endfor %} 61 |
62 | {% endif %} 63 | 64 | 65 |
66 |
67 | {% endif %} 68 | 69 |
70 |
71 | Upload Tasks 72 |
73 |
74 |
75 | {% csrf_token %} 76 |
77 | 78 |
79 | 80 |
81 |
82 |
83 | 84 | {% endblock %} 85 | -------------------------------------------------------------------------------- /todo/templates/todo/include/task_edit.html: -------------------------------------------------------------------------------- 1 | {# Form used by both Add Task and Edit Task views #} 2 | 3 |
4 | {% csrf_token %} 5 |
6 |
7 | 8 | 10 |
11 | 12 |
13 | 14 | 16 | 17 | Describe the task or bug. Provide steps to reproduce the issue. 18 | 19 |
20 | 21 |
22 | 23 | 25 |
26 | 27 |
28 | 29 | {# See todo.forms.AddEditTaskForm #} 30 | {{form.assigned_to}} 31 |
32 | 33 |
34 |
35 | 36 | 39 | 40 | Email notifications will only be sent if task is assigned to someone other than yourself. 41 | 42 |
43 |
44 | 45 | 47 | 48 | 49 | 50 |

51 | 52 |

53 | 54 |
55 |
56 | -------------------------------------------------------------------------------- /test_settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | DEBUG = (True,) 4 | BASE_DIR = os.path.dirname(os.path.dirname(__file__)) 5 | DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3"}} 6 | 7 | EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" 8 | 9 | # Document 10 | TODO_DEFAULT_LIST_SLUG = "tickets" 11 | TODO_DEFAULT_ASSIGNEE = None 12 | TODO_PUBLIC_SUBMIT_REDIRECT = "/" 13 | 14 | SECRET_KEY = "LKFSD8sdl.,8&sdf--" 15 | 16 | SITE_ID = 1 17 | 18 | INSTALLED_APPS = ( 19 | "django.contrib.admin", 20 | "django.contrib.auth", 21 | "django.contrib.contenttypes", 22 | "django.contrib.messages", 23 | "django.contrib.sessions", 24 | "django.contrib.sites", 25 | "django.contrib.staticfiles", 26 | "todo", 27 | "dal", 28 | "dal_select2", 29 | ) 30 | 31 | ROOT_URLCONF = "base_urls" 32 | 33 | MIDDLEWARE = [ 34 | "django.middleware.security.SecurityMiddleware", 35 | "django.contrib.sessions.middleware.SessionMiddleware", 36 | "django.middleware.common.CommonMiddleware", 37 | "django.middleware.csrf.CsrfViewMiddleware", 38 | "django.contrib.auth.middleware.AuthenticationMiddleware", 39 | "django.contrib.messages.middleware.MessageMiddleware", 40 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 41 | ] 42 | 43 | TEMPLATES = [ 44 | { 45 | "BACKEND": "django.template.backends.django.DjangoTemplates", 46 | "DIRS": [os.path.join(BASE_DIR, "todo", "templates")], 47 | "APP_DIRS": True, 48 | "OPTIONS": { 49 | "context_processors": [ 50 | "django.template.context_processors.debug", 51 | "django.template.context_processors.request", 52 | "django.contrib.auth.context_processors.auth", 53 | "django.template.context_processors.media", 54 | "django.template.context_processors.static", 55 | "django.contrib.messages.context_processors.messages", 56 | # Your stuff: custom template context processors go here 57 | ] 58 | }, 59 | } 60 | ] 61 | 62 | LOGGING = { 63 | "version": 1, 64 | "disable_existing_loggers": False, 65 | "handlers": {"console": {"class": "logging.StreamHandler"}}, 66 | "loggers": { 67 | "": {"handlers": ["console"], "level": "DEBUG", "propagate": True}, 68 | "django": {"handlers": ["console"], "level": "WARNING", "propagate": True}, 69 | "django.request": {"handlers": ["console"], "level": "DEBUG", "propagate": True}, 70 | }, 71 | } 72 | 73 | TODO_MAIL_USER_MAPPER = None 74 | -------------------------------------------------------------------------------- /todo/tests/test_import.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from pathlib import Path 3 | 4 | import pytest 5 | from django.contrib.auth import get_user_model 6 | 7 | from todo.models import Task, TaskList 8 | from todo.operations.csv_importer import CSVImporter 9 | 10 | 11 | """ 12 | Exercise the "Import CSV" feature, which shares a functional module that serves 13 | both the `import_csv` management command and the "Import CSV" web interface. 14 | """ 15 | 16 | 17 | @pytest.mark.django_db 18 | @pytest.fixture 19 | def import_setup(todo_setup): 20 | app_path = Path(__file__).resolve().parent.parent 21 | filepath = Path(app_path, "tests/data/csv_import_data.csv") 22 | with filepath.open(mode="r", encoding="utf-8-sig") as fileobj: 23 | importer = CSVImporter() 24 | results = importer.upsert(fileobj, as_string_obj=True) 25 | assert results 26 | return {"results": results} 27 | 28 | 29 | @pytest.mark.django_db 30 | def test_setup(todo_setup): 31 | """Confirm what we should have from conftest, prior to importing CSV.""" 32 | assert TaskList.objects.all().count() == 2 33 | assert Task.objects.all().count() == 6 34 | 35 | 36 | @pytest.mark.django_db 37 | def test_import(import_setup): 38 | """Confirm that importing the CSV gave us two more rows (one should have been skipped)""" 39 | assert Task.objects.all().count() == 8 # 2 out of 3 rows should have imported; one was an error 40 | 41 | 42 | @pytest.mark.django_db 43 | def test_report(import_setup): 44 | """Confirm that importing the CSV returned expected report messaging.""" 45 | 46 | results = import_setup["results"] 47 | 48 | assert "Processed 3 CSV rows" in results["summaries"] 49 | assert "Upserted 2 rows" in results["summaries"] 50 | assert "Skipped 1 rows" in results["summaries"] 51 | 52 | assert isinstance(results["errors"], list) 53 | assert len(results["errors"]) == 1 54 | assert ( 55 | results["errors"][0].get(3)[0] 56 | == "Could not convert Created Date 2015-06-248 to valid date instance" 57 | ) 58 | 59 | assert ( 60 | 'Upserted task 7: "Make dinner" in list "Zip" (group "Workgroup One")' in results["upserts"] 61 | ) 62 | assert ( 63 | 'Upserted task 8: "Bake bread" in list "Zip" (group "Workgroup One")' in results["upserts"] 64 | ) 65 | 66 | 67 | @pytest.mark.django_db 68 | def test_inserted_row(import_setup): 69 | """Confirm that one inserted row is exactly right.""" 70 | task = Task.objects.get(title="Make dinner", task_list__name="Zip") 71 | assert task.created_by == get_user_model().objects.get(username="u1") 72 | assert task.assigned_to == get_user_model().objects.get(username="u1") 73 | assert not task.completed 74 | assert task.note == "This is note one" 75 | assert task.priority == 3 76 | assert task.created_date == datetime.datetime.today().date() 77 | -------------------------------------------------------------------------------- /todo/tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from django.core import mail 2 | 3 | from todo.defaults import defaults 4 | from todo.models import Comment, Task 5 | from todo.utils import send_email_to_thread_participants, send_notify_mail 6 | 7 | 8 | def test_send_notify_mail_not_me(todo_setup, django_user_model, email_backend_setup): 9 | """Assign a task to someone else, mail should be sent. 10 | TODO: Future tests could check for email contents. 11 | """ 12 | 13 | u1 = django_user_model.objects.get(username="u1") 14 | u2 = django_user_model.objects.get(username="u2") 15 | 16 | task = Task.objects.filter(created_by=u1).first() 17 | task.assigned_to = u2 18 | task.save() 19 | send_notify_mail(task) 20 | assert len(mail.outbox) == 1 21 | 22 | 23 | def test_send_notify_mail_myself(todo_setup, django_user_model, email_backend_setup): 24 | """Assign a task to myself, no mail should be sent. 25 | """ 26 | 27 | u1 = django_user_model.objects.get(username="u1") 28 | task = Task.objects.filter(created_by=u1).first() 29 | task.assigned_to = u1 30 | task.save() 31 | send_notify_mail(task) 32 | assert len(mail.outbox) == 0 33 | 34 | 35 | def test_send_email_to_thread_participants(todo_setup, django_user_model, email_backend_setup): 36 | """For a given task authored by one user, add comments by two other users. 37 | Notification email should be sent to all three users.""" 38 | 39 | u1 = django_user_model.objects.get(username="u1") 40 | task = Task.objects.filter(created_by=u1).first() 41 | 42 | u3 = django_user_model.objects.create_user( 43 | username="u3", password="zzz", email="u3@example.com" 44 | ) 45 | u4 = django_user_model.objects.create_user( 46 | username="u4", password="zzz", email="u4@example.com" 47 | ) 48 | Comment.objects.create(author=u3, task=task, body="Hello") 49 | Comment.objects.create(author=u4, task=task, body="Hello") 50 | 51 | send_email_to_thread_participants(task, "test body", u1) 52 | assert len(mail.outbox) == 1 # One message to multiple recipients 53 | assert "u1@example.com" in mail.outbox[0].recipients() 54 | assert "u3@example.com" in mail.outbox[0].recipients() 55 | assert "u4@example.com" in mail.outbox[0].recipients() 56 | 57 | 58 | def test_defaults(settings): 59 | """todo's `defaults` module provides reasonable default values for unspecified settings. 60 | If a value is NOT set, it should be pulled from the hash in defaults.py. 61 | If a value IS set, it should be respected. 62 | n.b. TODO_STAFF_ONLY which defaults to True in the `defaults` module.""" 63 | 64 | key = "TODO_STAFF_ONLY" 65 | 66 | # Setting is not set, and should default to True (the value in defaults.py) 67 | assert not hasattr(settings, key) 68 | assert defaults(key) 69 | 70 | # Setting is already set to True and should be respected. 71 | settings.TODO_STAFF_ONLY = True 72 | assert defaults(key) 73 | 74 | # Setting is already set to False and should be respected. 75 | settings.TODO_STAFF_ONLY = False 76 | assert not defaults(key) 77 | 78 | 79 | # FIXME: Add tests for: 80 | # Attachments: Test whether allowed, test multiple, test extensions 81 | -------------------------------------------------------------------------------- /todo/views/list_detail.py: -------------------------------------------------------------------------------- 1 | import bleach 2 | from django.contrib import messages 3 | from django.contrib.auth.decorators import login_required, user_passes_test 4 | from django.core.exceptions import PermissionDenied 5 | from django.http import HttpResponse 6 | from django.shortcuts import get_object_or_404, redirect, render 7 | from django.utils import timezone 8 | 9 | from todo.forms import AddEditTaskForm 10 | from todo.models import Task, TaskList 11 | from todo.utils import send_notify_mail, staff_check 12 | 13 | 14 | @login_required 15 | @user_passes_test(staff_check) 16 | def list_detail(request, list_id=None, list_slug=None, view_completed=False) -> HttpResponse: 17 | """Display and manage tasks in a todo list. 18 | """ 19 | 20 | # Defaults 21 | task_list = None 22 | form = None 23 | 24 | # Which tasks to show on this list view? 25 | if list_slug == "mine": 26 | tasks = Task.objects.filter(assigned_to=request.user) 27 | 28 | else: 29 | # Show a specific list, ensuring permissions. 30 | task_list = get_object_or_404(TaskList, id=list_id) 31 | if task_list.group not in request.user.groups.all() and not request.user.is_superuser: 32 | raise PermissionDenied 33 | tasks = Task.objects.filter(task_list=task_list.id) 34 | 35 | # Additional filtering 36 | if view_completed: 37 | tasks = tasks.filter(completed=True) 38 | else: 39 | tasks = tasks.filter(completed=False) 40 | 41 | # ###################### 42 | # Add New Task Form 43 | # ###################### 44 | 45 | if request.POST.getlist("add_edit_task"): 46 | form = AddEditTaskForm( 47 | request.user, 48 | request.POST, 49 | initial={"assigned_to": request.user.id, "priority": 999, "task_list": task_list}, 50 | ) 51 | 52 | if form.is_valid(): 53 | new_task = form.save(commit=False) 54 | new_task.created_by = request.user 55 | new_task.note = bleach.clean(form.cleaned_data["note"], strip=True) 56 | form.save() 57 | 58 | # Send email alert only if Notify checkbox is checked AND assignee is not same as the submitter 59 | if ( 60 | "notify" in request.POST 61 | and new_task.assigned_to 62 | and new_task.assigned_to != request.user 63 | ): 64 | send_notify_mail(new_task) 65 | 66 | messages.success(request, 'New task "{t}" has been added.'.format(t=new_task.title)) 67 | return redirect(request.path) 68 | else: 69 | # Don't allow adding new tasks on some views 70 | if list_slug not in ["mine", "recent-add", "recent-complete"]: 71 | form = AddEditTaskForm( 72 | request.user, 73 | initial={"assigned_to": request.user.id, "priority": 999, "task_list": task_list}, 74 | ) 75 | 76 | context = { 77 | "list_id": list_id, 78 | "list_slug": list_slug, 79 | "task_list": task_list, 80 | "form": form, 81 | "tasks": tasks, 82 | "view_completed": view_completed, 83 | } 84 | 85 | return render(request, "todo/list_detail.html", context) 86 | -------------------------------------------------------------------------------- /todo/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.contrib.auth.models import Group 3 | from django.forms import ModelForm 4 | from todo.models import Task, TaskList 5 | 6 | 7 | class AddTaskListForm(ModelForm): 8 | """The picklist showing allowable groups to which a new list can be added 9 | determines which groups the user belongs to. This queries the form object 10 | to derive that list.""" 11 | 12 | def __init__(self, user, *args, **kwargs): 13 | super(AddTaskListForm, self).__init__(*args, **kwargs) 14 | self.fields["group"].queryset = Group.objects.filter(user=user) 15 | self.fields["group"].widget.attrs = { 16 | "id": "id_group", 17 | "class": "custom-select mb-3", 18 | "name": "group", 19 | } 20 | 21 | class Meta: 22 | model = TaskList 23 | exclude = ["created_date", "slug"] 24 | 25 | 26 | class AddEditTaskForm(ModelForm): 27 | """The picklist showing the users to which a new task can be assigned 28 | must find other members of the group this TaskList is attached to.""" 29 | 30 | def __init__(self, user, *args, **kwargs): 31 | super().__init__(*args, **kwargs) 32 | task_list = kwargs.get("initial").get("task_list") 33 | members = task_list.group.user_set.all() 34 | self.fields["assigned_to"].queryset = members 35 | self.fields["assigned_to"].label_from_instance = lambda obj: "%s (%s)" % ( 36 | obj.get_full_name(), 37 | obj.username, 38 | ) 39 | self.fields["assigned_to"].widget.attrs = { 40 | "id": "id_assigned_to", 41 | "class": "custom-select mb-3", 42 | "name": "assigned_to", 43 | } 44 | self.fields["task_list"].value = kwargs["initial"]["task_list"].id 45 | 46 | due_date = forms.DateField(widget=forms.DateInput(attrs={"type": "date"}), required=False) 47 | 48 | title = forms.CharField(widget=forms.widgets.TextInput()) 49 | 50 | note = forms.CharField(widget=forms.Textarea(), required=False) 51 | 52 | completed = forms.BooleanField(required=False) 53 | 54 | def clean_created_by(self): 55 | """Keep the existing created_by regardless of anything coming from the submitted form. 56 | If creating a new task, then created_by will be None, but we set it before saving.""" 57 | return self.instance.created_by 58 | 59 | class Meta: 60 | model = Task 61 | exclude = [] 62 | 63 | 64 | class AddExternalTaskForm(ModelForm): 65 | """Form to allow users who are not part of the GTD system to file a ticket.""" 66 | 67 | title = forms.CharField(widget=forms.widgets.TextInput(attrs={"size": 35}), label="Summary") 68 | note = forms.CharField(widget=forms.widgets.Textarea(), label="Problem Description") 69 | priority = forms.IntegerField(widget=forms.HiddenInput()) 70 | 71 | class Meta: 72 | model = Task 73 | exclude = ( 74 | "task_list", 75 | "created_date", 76 | "due_date", 77 | "created_by", 78 | "assigned_to", 79 | "completed", 80 | "completed_date", 81 | ) 82 | 83 | 84 | class SearchForm(forms.Form): 85 | """Search.""" 86 | 87 | q = forms.CharField(widget=forms.widgets.TextInput(attrs={"size": 35})) 88 | -------------------------------------------------------------------------------- /todo/mail/producers/imap.py: -------------------------------------------------------------------------------- 1 | import email 2 | import email.parser 3 | import imaplib 4 | import logging 5 | import time 6 | 7 | from email.policy import default 8 | from contextlib import contextmanager 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | def imap_check(command_tuple): 14 | status, ids = command_tuple 15 | assert status == "OK", ids 16 | 17 | 18 | @contextmanager 19 | def imap_connect(host, port, username, password): 20 | conn = imaplib.IMAP4_SSL(host=host, port=port) 21 | conn.login(username, password) 22 | imap_check(conn.list()) 23 | try: 24 | yield conn 25 | finally: 26 | conn.close() 27 | 28 | 29 | def parse_message(message): 30 | for response_part in message: 31 | if not isinstance(response_part, tuple): 32 | continue 33 | 34 | message_metadata, message_content = response_part 35 | email_parser = email.parser.BytesFeedParser(policy=default) 36 | email_parser.feed(message_content) 37 | return email_parser.close() 38 | 39 | 40 | def search_message(conn, *filters): 41 | status, message_ids = conn.search(None, *filters) 42 | for message_id in message_ids[0].split(): 43 | status, message = conn.fetch(message_id, "(RFC822)") 44 | yield message_id, parse_message(message) 45 | 46 | 47 | def imap_producer( 48 | process_all=False, 49 | preserve=False, 50 | host=None, 51 | port=993, 52 | username=None, 53 | password=None, 54 | nap_duration=1, 55 | input_folder="INBOX", 56 | ): 57 | logger.debug("starting IMAP worker") 58 | imap_filter = "(ALL)" if process_all else "(UNSEEN)" 59 | 60 | def process_batch(): 61 | logger.debug("starting to process batch") 62 | # reconnect each time to avoid repeated failures due to a lost connection 63 | with imap_connect(host, port, username, password) as conn: 64 | # select the requested folder 65 | imap_check(conn.select(input_folder, readonly=False)) 66 | 67 | try: 68 | for message_uid, message in search_message(conn, imap_filter): 69 | logger.info(f"received message {message_uid}") 70 | try: 71 | yield message 72 | except Exception: 73 | logger.exception(f"something went wrong while processing {message_uid}") 74 | raise 75 | 76 | if not preserve: 77 | # tag the message for deletion 78 | conn.store(message_uid, "+FLAGS", "\\Deleted") 79 | else: 80 | logger.debug("did not receive any message") 81 | finally: 82 | if not preserve: 83 | # flush deleted messages 84 | conn.expunge() 85 | 86 | while True: 87 | try: 88 | yield from process_batch() 89 | except (GeneratorExit, KeyboardInterrupt): 90 | # the generator was closed, due to the consumer 91 | # breaking out of the loop, or an exception occuring 92 | raise 93 | except Exception: 94 | logger.exception("mail fetching went wrong, retrying") 95 | 96 | # sleep to avoid using too much resources 97 | # TODO: get notified when a new message arrives 98 | time.sleep(nap_duration) 99 | -------------------------------------------------------------------------------- /todo/views/external_add.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib import messages 3 | from django.contrib.auth.decorators import login_required, user_passes_test 4 | from django.contrib.auth import get_user_model 5 | from django.contrib.sites.models import Site 6 | from django.core.mail import send_mail 7 | from django.http import HttpResponse 8 | from django.shortcuts import redirect, render 9 | from django.template.loader import render_to_string 10 | 11 | from todo.defaults import defaults 12 | from todo.forms import AddExternalTaskForm 13 | from todo.models import TaskList 14 | from todo.utils import staff_check 15 | 16 | 17 | @login_required 18 | @user_passes_test(staff_check) 19 | def external_add(request) -> HttpResponse: 20 | """Allow authenticated users who don't have access to the rest of the ticket system to file a ticket 21 | in the list specified in settings (e.g. django-todo can be used a ticket filing system for a school, where 22 | students can file tickets without access to the rest of the todo system). 23 | 24 | Publicly filed tickets are unassigned unless settings.DEFAULT_ASSIGNEE exists. 25 | """ 26 | 27 | if not settings.TODO_DEFAULT_LIST_SLUG: 28 | # We do NOT provide a default in defaults 29 | raise RuntimeError( 30 | "This feature requires TODO_DEFAULT_LIST_SLUG: in settings. See documentation." 31 | ) 32 | 33 | if not TaskList.objects.filter(slug=settings.TODO_DEFAULT_LIST_SLUG).exists(): 34 | raise RuntimeError( 35 | "There is no TaskList with slug specified for TODO_DEFAULT_LIST_SLUG in settings." 36 | ) 37 | 38 | if request.POST: 39 | form = AddExternalTaskForm(request.POST) 40 | 41 | if form.is_valid(): 42 | current_site = Site.objects.get_current() 43 | task = form.save(commit=False) 44 | task.task_list = TaskList.objects.get(slug=settings.TODO_DEFAULT_LIST_SLUG) 45 | task.created_by = request.user 46 | if defaults("TODO_DEFAULT_ASSIGNEE"): 47 | task.assigned_to = get_user_model().objects.get(username=settings.TODO_DEFAULT_ASSIGNEE) 48 | task.save() 49 | 50 | # Send email to assignee if we have one 51 | if task.assigned_to: 52 | email_subject = render_to_string( 53 | "todo/email/assigned_subject.txt", {"task": task.title} 54 | ) 55 | email_body = render_to_string( 56 | "todo/email/assigned_body.txt", {"task": task, "site": current_site} 57 | ) 58 | try: 59 | send_mail( 60 | email_subject, 61 | email_body, 62 | task.created_by.email, 63 | [task.assigned_to.email], 64 | fail_silently=False, 65 | ) 66 | except ConnectionRefusedError: 67 | messages.warning( 68 | request, "Task saved but mail not sent. Contact your administrator." 69 | ) 70 | 71 | messages.success( 72 | request, "Your trouble ticket has been submitted. We'll get back to you soon." 73 | ) 74 | return redirect(defaults("TODO_PUBLIC_SUBMIT_REDIRECT")) 75 | 76 | else: 77 | form = AddExternalTaskForm(initial={"priority": 999}) 78 | 79 | context = {"form": form} 80 | 81 | return render(request, "todo/add_task_external.html", context) 82 | -------------------------------------------------------------------------------- /todo/templates/todo/list_detail.html: -------------------------------------------------------------------------------- 1 | {% extends "todo/base.html" %} 2 | {% load static %} 3 | 4 | {% block title %}Todo List: {{ task_list.name }}{% endblock %} 5 | 6 | {% block content %} 7 | 8 | {% if list_slug != "mine" %} 9 | 11 | 12 | {# Task edit / new task form #} 13 |
14 | {% include 'todo/include/task_edit.html' %} 15 |
16 |
17 | {% endif %} 18 | 19 | {% if tasks %} 20 | {% if list_slug == "mine" %} 21 |

Tasks assigned to me (in all groups)

22 | {% else %} 23 |

{{ view_completed|yesno:"Completed tasks, Tasks" }} in "{{ task_list.name }}"

24 |

In workgroup "{{ task_list.group }}" - drag rows to set priorities.

25 | {% endif %} 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | {% for task in tasks %} 38 | 39 | 42 | 45 | 50 | 53 | 56 | 68 | 69 | {% endfor %} 70 |
TaskCreatedDue onOwnerAssignedMark
40 | {{ task.title|truncatewords:10 }} 41 | 43 | {{ task.created_date|date:"m/d/Y" }} 44 | 46 | 47 | {{ task.due_date|date:"m/d/Y" }} 48 | 49 | 51 | {{ task.created_by }} 52 | 54 | {% if task.assigned_to %}{{ task.assigned_to }}{% else %}Anyone{% endif %} 55 | 57 |
58 | {% csrf_token %} 59 | 66 |
67 |
71 | 72 | {% include 'todo/include/toggle_delete.html' %} 73 | 74 | {% else %} 75 |

No tasks on this list yet (add one!)

76 | {% include 'todo/include/toggle_delete.html' %} 77 | 78 | {% endif %} 79 | 80 | {% endblock %} 81 | 82 | {% block extra_js %} 83 | 84 | 85 | 114 | {% endblock extra_js %} 115 | -------------------------------------------------------------------------------- /todo/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | import datetime 6 | from django.conf import settings 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ("auth", "0001_initial"), 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name="Comment", 19 | fields=[ 20 | ( 21 | "id", 22 | models.AutoField( 23 | verbose_name="ID", serialize=False, auto_created=True, primary_key=True 24 | ), 25 | ), 26 | ("date", models.DateTimeField(default=datetime.datetime.now)), 27 | ("body", models.TextField(blank=True)), 28 | ( 29 | "author", 30 | models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE), 31 | ), 32 | ], 33 | options={}, 34 | bases=(models.Model,), 35 | ), 36 | migrations.CreateModel( 37 | name="Item", 38 | fields=[ 39 | ( 40 | "id", 41 | models.AutoField( 42 | verbose_name="ID", serialize=False, auto_created=True, primary_key=True 43 | ), 44 | ), 45 | ("title", models.CharField(max_length=140)), 46 | ("created_date", models.DateField(auto_now=True, auto_now_add=True)), 47 | ("due_date", models.DateField(null=True, blank=True)), 48 | ("completed", models.BooleanField(default=None)), 49 | ("completed_date", models.DateField(null=True, blank=True)), 50 | ("note", models.TextField(null=True, blank=True)), 51 | ("priority", models.PositiveIntegerField(max_length=3)), 52 | ( 53 | "assigned_to", 54 | models.ForeignKey( 55 | related_name="todo_assigned_to", 56 | to=settings.AUTH_USER_MODEL, 57 | on_delete=models.CASCADE, 58 | ), 59 | ), 60 | ( 61 | "created_by", 62 | models.ForeignKey( 63 | related_name="todo_created_by", 64 | to=settings.AUTH_USER_MODEL, 65 | on_delete=models.CASCADE, 66 | ), 67 | ), 68 | ], 69 | options={"ordering": ["priority"]}, 70 | bases=(models.Model,), 71 | ), 72 | migrations.CreateModel( 73 | name="List", 74 | fields=[ 75 | ( 76 | "id", 77 | models.AutoField( 78 | verbose_name="ID", serialize=False, auto_created=True, primary_key=True 79 | ), 80 | ), 81 | ("name", models.CharField(max_length=60)), 82 | ("slug", models.SlugField(max_length=60, editable=False)), 83 | ("group", models.ForeignKey(to="auth.Group", on_delete=models.CASCADE)), 84 | ], 85 | options={"ordering": ["name"], "verbose_name_plural": "Lists"}, 86 | bases=(models.Model,), 87 | ), 88 | migrations.AlterUniqueTogether(name="list", unique_together=set([("group", "slug")])), 89 | migrations.AddField( 90 | model_name="item", 91 | name="list", 92 | field=models.ForeignKey(to="todo.List", on_delete=models.CASCADE), 93 | preserve_default=True, 94 | ), 95 | migrations.AddField( 96 | model_name="comment", 97 | name="task", 98 | field=models.ForeignKey(to="todo.Item", on_delete=models.CASCADE), 99 | preserve_default=True, 100 | ), 101 | ] 102 | -------------------------------------------------------------------------------- /todo/tests/test_tracker.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from django.core import mail 4 | 5 | from todo.models import Task, Comment 6 | from todo.mail.consumers import tracker_consumer 7 | from email.message import EmailMessage 8 | 9 | 10 | def consumer(*args, title_format="[TEST] {subject}", **kwargs): 11 | return tracker_consumer( 12 | group="Workgroup One", task_list_slug="zip", priority=1, task_title_format=title_format 13 | )(*args, **kwargs) 14 | 15 | 16 | def make_message(subject, content): 17 | msg = EmailMessage() 18 | msg.set_content(content) 19 | msg["Subject"] = subject 20 | return msg 21 | 22 | 23 | def test_tracker_task_creation(todo_setup, django_user_model): 24 | msg = make_message("test1 subject", "test1 content") 25 | msg["From"] = "test1@example.com" 26 | msg["Message-ID"] = "" 27 | 28 | # test task creation 29 | task_count = Task.objects.count() 30 | consumer([msg]) 31 | 32 | assert task_count + 1 == Task.objects.count(), "task wasn't created" 33 | task = Task.objects.filter(title="[TEST] test1 subject").first() 34 | assert task is not None, "task was created with the wrong name" 35 | 36 | # test thread answers 37 | msg = make_message("test2 subject", "test2 content") 38 | msg["From"] = "test1@example.com" 39 | msg["Message-ID"] = "" 40 | msg["References"] = " " 41 | 42 | task_count = Task.objects.count() 43 | consumer([msg]) 44 | assert task_count == Task.objects.count(), "comment created another task" 45 | Comment.objects.get( 46 | task=task, body__contains="test2 content", email_message_id="" 47 | ) 48 | 49 | # test notification answer 50 | msg = make_message("test3 subject", "test3 content") 51 | msg["From"] = "test1@example.com" 52 | msg["Message-ID"] = "" 53 | msg["References"] = " ".format(task.pk) 54 | 55 | task_count = Task.objects.count() 56 | consumer([msg]) 57 | assert task_count == Task.objects.count(), "comment created another task" 58 | Comment.objects.get( 59 | task=task, body__contains="test3 content", email_message_id="" 60 | ) 61 | 62 | def test_tracker_email_match(todo_setup, django_user_model, settings): 63 | """ 64 | Ensure that a user is added to new lists when sent from registered email 65 | """ 66 | settings.TODO_MAIL_USER_MAPPER = True 67 | 68 | u1 = django_user_model.objects.get(username="u1") 69 | 70 | msg = make_message("test1 subject", "test1 content") 71 | msg["From"] = u1.email 72 | msg["Message-ID"] = "" 73 | 74 | # test task creation 75 | task_count = Task.objects.count() 76 | consumer([msg]) 77 | 78 | assert task_count + 1 == Task.objects.count(), "task wasn't created" 79 | task = Task.objects.filter(title="[TEST] test1 subject").first() 80 | assert task is not None, "task was created with the wrong name" 81 | assert task.created_by == u1 82 | 83 | # Check no match 84 | msg = make_message("test2 subject", "test2 content") 85 | msg["From"] = "no-match-email@example.com" 86 | msg["Message-ID"] = "" 87 | 88 | # test task creation 89 | task_count = Task.objects.count() 90 | consumer([msg]) 91 | 92 | assert task_count + 1 == Task.objects.count(), "task wasn't created" 93 | task = Task.objects.filter(title="[TEST] test2 subject").first() 94 | assert task.created_by == None 95 | 96 | 97 | def test_tracker_match_users_false(todo_setup, django_user_model, settings): 98 | """ 99 | Do not match users on incoming mail if TODO_MAIL_USER_MAPPER is False 100 | """ 101 | settings.TODO_MAIL_USER_MAPPER = None 102 | 103 | u1 = django_user_model.objects.get(username="u1") 104 | 105 | msg = make_message("test1 subject", "test1 content") 106 | msg["From"] = u1.email 107 | msg["Message-ID"] = "" 108 | 109 | # test task creation 110 | task_count = Task.objects.count() 111 | consumer([msg]) 112 | 113 | assert task_count + 1 == Task.objects.count(), "task wasn't created" 114 | task = Task.objects.filter(title="[TEST] test1 subject").first() 115 | assert task is not None, "task was created with the wrong name" 116 | assert task.created_by == None 117 | -------------------------------------------------------------------------------- /todo/management/commands/hopper.py: -------------------------------------------------------------------------------- 1 | import factory 2 | from faker import Faker 3 | from titlecase import titlecase 4 | import random 5 | 6 | from django.core.management.base import BaseCommand 7 | from django.contrib.auth.models import Group 8 | from django.contrib.auth import get_user_model 9 | from django.utils.text import slugify 10 | 11 | from todo.models import Task, TaskList 12 | 13 | 14 | num_lists = 5 15 | 16 | 17 | def gen_title(tc=True): 18 | # faker doesn't provide a way to generate headlines in Title Case, without periods, so make our own. 19 | # With arg `tc=True`, Title Cases The Generated Text 20 | fake = Faker() 21 | thestr = fake.text(max_nb_chars=32).rstrip(".") 22 | if tc: 23 | thestr = titlecase(thestr) 24 | 25 | return thestr 26 | 27 | 28 | def gen_content(): 29 | # faker provides paragraphs as a list; convert with linebreaks 30 | fake = Faker() 31 | grafs = fake.paragraphs() 32 | thestr = "" 33 | for g in grafs: 34 | thestr += "{}\n\n".format(g) 35 | return thestr 36 | 37 | 38 | class Command(BaseCommand): 39 | help = """Create random list and task data for a few fake users.""" 40 | 41 | def add_arguments(self, parser): 42 | parser.add_argument( 43 | "-d", 44 | "--delete", 45 | help="Wipe out existing content before generating new.", 46 | action="store_true", 47 | ) 48 | 49 | def handle(self, *args, **options): 50 | 51 | if options.get("delete"): 52 | # Wipe out previous contents? Cascade deletes the Tasks from the TaskLists. 53 | TaskList.objects.all().delete() 54 | print("Content from previous run deleted.") 55 | print("Working...") 56 | 57 | fake = Faker() # Use to create user's names 58 | 59 | # Create users and groups, add different users to different groups. Staff user is in both groups. 60 | sd_group, created = Group.objects.get_or_create(name="Scuba Divers") 61 | bw_group, created = Group.objects.get_or_create(name="Basket Weavers") 62 | 63 | # Put user1 and user2 in one group, user3 and user4 in another 64 | usernames = ["user1", "user2", "user3", "user4", "staffer"] 65 | for username in usernames: 66 | if get_user_model().objects.filter(username=username).exists(): 67 | user = get_user_model().objects.get(username=username) 68 | else: 69 | user = get_user_model().objects.create_user( 70 | username=username, 71 | first_name=fake.first_name(), 72 | last_name=fake.last_name(), 73 | email="{}@example.com".format(username), 74 | password="todo", 75 | ) 76 | 77 | if username in ["user1", "user2"]: 78 | user.groups.add(bw_group) 79 | 80 | if username in ["user3", "user4"]: 81 | user.groups.add(sd_group) 82 | 83 | if username == "staffer": 84 | user.is_staff = True 85 | user.first_name = fake.first_name() 86 | user.last_name = fake.last_name() 87 | user.save() 88 | user.groups.add(bw_group) 89 | user.groups.add(sd_group) 90 | 91 | # Create lists with tasks, plus one with fixed name for externally added tasks 92 | TaskListFactory.create_batch(5, group=bw_group) 93 | TaskListFactory.create_batch(5, group=sd_group) 94 | TaskListFactory.create(name="Public Tickets", slug="tickets", group=bw_group) 95 | 96 | print( 97 | "For each of two groups, created fake tasks in each of {} fake lists.".format(num_lists) 98 | ) 99 | 100 | 101 | class TaskListFactory(factory.django.DjangoModelFactory): 102 | """Group not generated here - call with group as arg.""" 103 | 104 | class Meta: 105 | model = TaskList 106 | 107 | name = factory.LazyAttribute(lambda o: gen_title(tc=True)) 108 | slug = factory.LazyAttribute(lambda o: slugify(o.name)) 109 | group = None # Pass this in 110 | 111 | @factory.post_generation 112 | def add_tasks(self, build, extracted, **kwargs): 113 | num = random.randint(5, 25) 114 | TaskFactory.create_batch(num, task_list=self) 115 | 116 | 117 | class TaskFactory(factory.django.DjangoModelFactory): 118 | """TaskList not generated here - call with TaskList as arg.""" 119 | 120 | class Meta: 121 | model = Task 122 | 123 | title = factory.LazyAttribute(lambda o: gen_title(tc=False)) 124 | task_list = None # Pass this in 125 | note = factory.LazyAttribute(lambda o: gen_content()) 126 | priority = factory.LazyAttribute(lambda o: random.randint(1, 100)) 127 | completed = factory.Faker("boolean", chance_of_getting_true=30) 128 | created_by = factory.LazyAttribute( 129 | lambda o: get_user_model().objects.get(username="staffer") 130 | ) # Randomized in post 131 | created_date = factory.Faker("date_this_year") 132 | 133 | @factory.post_generation 134 | def add_details(self, build, extracted, **kwargs): 135 | 136 | fake = Faker() # Use to create user's names 137 | taskgroup = self.task_list.group 138 | 139 | self.created_by = taskgroup.user_set.all().order_by("?").first() 140 | 141 | if self.completed: 142 | self.completed_date = fake.date_this_year() 143 | 144 | # 1/3 of generated tasks have a due_date 145 | if random.randint(1, 3) == 1: 146 | self.due_date = fake.date_this_year() 147 | 148 | # 1/3 of generated tasks are assigned to someone in this tasks's group 149 | if random.randint(1, 3) == 1: 150 | self.assigned_to = taskgroup.user_set.all().order_by("?").first() 151 | 152 | self.save() 153 | -------------------------------------------------------------------------------- /todo/views/task_detail.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import os 3 | 4 | import bleach 5 | from django import forms 6 | from django.conf import settings 7 | from django.contrib import messages 8 | from django.contrib.auth.decorators import login_required, user_passes_test 9 | from django.core.exceptions import PermissionDenied 10 | from django.http import HttpResponse 11 | from django.shortcuts import get_object_or_404, redirect, render 12 | from django.urls import reverse 13 | 14 | from todo.defaults import defaults 15 | from todo.features import HAS_TASK_MERGE 16 | from todo.forms import AddEditTaskForm 17 | from todo.models import Attachment, Comment, Task 18 | from todo.utils import ( 19 | send_email_to_thread_participants, 20 | staff_check, 21 | toggle_task_completed, 22 | user_can_read_task, 23 | ) 24 | 25 | if HAS_TASK_MERGE: 26 | from dal import autocomplete 27 | 28 | 29 | def handle_add_comment(request, task): 30 | if not request.POST.get("add_comment"): 31 | return 32 | 33 | Comment.objects.create( 34 | author=request.user, task=task, body=bleach.clean(request.POST["comment-body"], strip=True) 35 | ) 36 | 37 | send_email_to_thread_participants( 38 | task, 39 | request.POST["comment-body"], 40 | request.user, 41 | subject='New comment posted on task "{}"'.format(task.title), 42 | ) 43 | 44 | messages.success(request, "Comment posted. Notification email sent to thread participants.") 45 | 46 | 47 | @login_required 48 | @user_passes_test(staff_check) 49 | def task_detail(request, task_id: int) -> HttpResponse: 50 | """View task details. Allow task details to be edited. Process new comments on task. 51 | """ 52 | 53 | task = get_object_or_404(Task, pk=task_id) 54 | comment_list = Comment.objects.filter(task=task_id).order_by("-date") 55 | 56 | # Ensure user has permission to view task. Superusers can view all tasks. 57 | # Get the group this task belongs to, and check whether current user is a member of that group. 58 | if not user_can_read_task(task, request.user): 59 | raise PermissionDenied 60 | 61 | # Handle task merging 62 | if not HAS_TASK_MERGE: 63 | merge_form = None 64 | else: 65 | 66 | class MergeForm(forms.Form): 67 | merge_target = forms.ModelChoiceField( 68 | queryset=Task.objects.all(), 69 | widget=autocomplete.ModelSelect2( 70 | url=reverse("todo:task_autocomplete", kwargs={"task_id": task_id}) 71 | ), 72 | ) 73 | 74 | # Handle task merging 75 | if not request.POST.get("merge_task_into"): 76 | merge_form = MergeForm() 77 | else: 78 | merge_form = MergeForm(request.POST) 79 | if merge_form.is_valid(): 80 | merge_target = merge_form.cleaned_data["merge_target"] 81 | if not user_can_read_task(merge_target, request.user): 82 | raise PermissionDenied 83 | 84 | task.merge_into(merge_target) 85 | return redirect(reverse("todo:task_detail", kwargs={"task_id": merge_target.pk})) 86 | 87 | # Save submitted comments 88 | handle_add_comment(request, task) 89 | 90 | # Save task edits 91 | if not request.POST.get("add_edit_task"): 92 | form = AddEditTaskForm(request.user, instance=task, initial={"task_list": task.task_list}) 93 | else: 94 | form = AddEditTaskForm( 95 | request.user, request.POST, instance=task, initial={"task_list": task.task_list} 96 | ) 97 | 98 | if form.is_valid(): 99 | item = form.save(commit=False) 100 | item.note = bleach.clean(form.cleaned_data["note"], strip=True) 101 | item.title = bleach.clean(form.cleaned_data["title"], strip=True) 102 | item.save() 103 | messages.success(request, "The task has been edited.") 104 | return redirect( 105 | "todo:list_detail", list_id=task.task_list.id, list_slug=task.task_list.slug 106 | ) 107 | 108 | # Mark complete 109 | if request.POST.get("toggle_done"): 110 | results_changed = toggle_task_completed(task.id) 111 | if results_changed: 112 | messages.success(request, f"Changed completion status for task {task.id}") 113 | 114 | return redirect("todo:task_detail", task_id=task.id) 115 | 116 | if task.due_date: 117 | thedate = task.due_date 118 | else: 119 | thedate = datetime.datetime.now() 120 | 121 | # Handle uploaded files 122 | if request.FILES.get("attachment_file_input"): 123 | file = request.FILES.get("attachment_file_input") 124 | 125 | if file.size > defaults("TODO_MAXIMUM_ATTACHMENT_SIZE"): 126 | messages.error(request, f"File exceeds maximum attachment size.") 127 | return redirect("todo:task_detail", task_id=task.id) 128 | 129 | name, extension = os.path.splitext(file.name) 130 | 131 | if extension not in defaults("TODO_LIMIT_FILE_ATTACHMENTS"): 132 | messages.error(request, f"This site does not allow upload of {extension} files.") 133 | return redirect("todo:task_detail", task_id=task.id) 134 | 135 | Attachment.objects.create( 136 | task=task, added_by=request.user, timestamp=datetime.datetime.now(), file=file 137 | ) 138 | messages.success(request, f"File attached successfully") 139 | return redirect("todo:task_detail", task_id=task.id) 140 | 141 | context = { 142 | "task": task, 143 | "comment_list": comment_list, 144 | "form": form, 145 | "merge_form": merge_form, 146 | "thedate": thedate, 147 | "comment_classes": defaults("TODO_COMMENT_CLASSES"), 148 | "attachments_enabled": defaults("TODO_ALLOW_FILE_ATTACHMENTS"), 149 | } 150 | 151 | return render(request, "todo/task_detail.html", context) 152 | -------------------------------------------------------------------------------- /todo/utils.py: -------------------------------------------------------------------------------- 1 | import email.utils 2 | import logging 3 | import os 4 | import time 5 | 6 | from django.conf import settings 7 | from django.contrib.sites.models import Site 8 | from django.core import mail 9 | from django.template.loader import render_to_string 10 | 11 | from todo.defaults import defaults 12 | from todo.models import Attachment, Comment, Task 13 | 14 | log = logging.getLogger(__name__) 15 | 16 | 17 | def staff_check(user): 18 | """If TODO_STAFF_ONLY is set to True, limit view access to staff users only. 19 | # FIXME: More granular access control needed - see 20 | https://github.com/shacker/django-todo/issues/50 21 | """ 22 | 23 | if defaults("TODO_STAFF_ONLY"): 24 | return user.is_staff 25 | else: 26 | # If unset or False, allow all logged in users 27 | return True 28 | 29 | 30 | def user_can_read_task(task, user): 31 | return task.task_list.group in user.groups.all() or user.is_superuser 32 | 33 | 34 | def todo_get_backend(task): 35 | """Returns a mail backend for some task""" 36 | mail_backends = getattr(settings, "TODO_MAIL_BACKENDS", None) 37 | if mail_backends is None: 38 | return None 39 | 40 | task_backend = mail_backends[task.task_list.slug] 41 | if task_backend is None: 42 | return None 43 | 44 | return task_backend 45 | 46 | 47 | def todo_get_mailer(user, task): 48 | """A mailer is a (from_address, backend) pair""" 49 | task_backend = todo_get_backend(task) 50 | if task_backend is None: 51 | return (None, mail.get_connection) 52 | 53 | from_address = getattr(task_backend, "from_address") 54 | from_address = email.utils.formataddr((user.username, from_address)) 55 | return (from_address, task_backend) 56 | 57 | 58 | def todo_send_mail(user, task, subject, body, recip_list): 59 | """Send an email attached to task, triggered by user""" 60 | references = Comment.objects.filter(task=task).only("email_message_id") 61 | references = (ref.email_message_id for ref in references) 62 | references = " ".join(filter(bool, references)) 63 | 64 | from_address, backend = todo_get_mailer(user, task) 65 | message_hash = hash((subject, body, from_address, frozenset(recip_list), references)) 66 | 67 | message_id = ( 68 | # the task_id enables attaching back notification answers 69 | "" 73 | ).format( 74 | task_id=task.pk, 75 | # avoid the -hexstring case (hashes can be negative) 76 | message_hash=abs(message_hash), 77 | epoch=int(time.time()), 78 | ) 79 | 80 | # the thread message id is used as a common denominator between all 81 | # notifications for some task. This message doesn't actually exist, 82 | # it's just there to make threading possible 83 | thread_message_id = "".format(task.pk) 84 | references = "{} {}".format(references, thread_message_id) 85 | 86 | with backend() as connection: 87 | message = mail.EmailMessage( 88 | subject, 89 | body, 90 | from_address, 91 | recip_list, 92 | [], # Bcc 93 | headers={ 94 | **getattr(backend, "headers", {}), 95 | "Message-ID": message_id, 96 | "References": references, 97 | "In-reply-to": thread_message_id, 98 | }, 99 | connection=connection, 100 | ) 101 | message.send() 102 | 103 | 104 | def send_notify_mail(new_task): 105 | """ 106 | Send email to assignee if task is assigned to someone other than submittor. 107 | Unassigned tasks should not try to notify. 108 | """ 109 | 110 | if new_task.assigned_to == new_task.created_by: 111 | return 112 | 113 | current_site = Site.objects.get_current() 114 | subject = render_to_string("todo/email/assigned_subject.txt", {"task": new_task}) 115 | body = render_to_string( 116 | "todo/email/assigned_body.txt", {"task": new_task, "site": current_site} 117 | ) 118 | 119 | recip_list = [new_task.assigned_to.email] 120 | todo_send_mail(new_task.created_by, new_task, subject, body, recip_list) 121 | 122 | 123 | def send_email_to_thread_participants(task, msg_body, user, subject=None): 124 | """Notify all previous commentors on a Task about a new comment.""" 125 | 126 | current_site = Site.objects.get_current() 127 | email_subject = subject 128 | if not subject: 129 | subject = render_to_string("todo/email/assigned_subject.txt", {"task": task}) 130 | 131 | email_body = render_to_string( 132 | "todo/email/newcomment_body.txt", 133 | {"task": task, "body": msg_body, "site": current_site, "user": user}, 134 | ) 135 | 136 | # Get all thread participants 137 | commenters = Comment.objects.filter(task=task) 138 | recip_list = set(ca.author.email for ca in commenters if ca.author is not None) 139 | for related_user in (task.created_by, task.assigned_to): 140 | if related_user is not None: 141 | recip_list.add(related_user.email) 142 | recip_list = list(m for m in recip_list if m) 143 | 144 | todo_send_mail(user, task, email_subject, email_body, recip_list) 145 | 146 | 147 | def toggle_task_completed(task_id: int) -> bool: 148 | """Toggle the `completed` bool on Task from True to False or vice versa.""" 149 | try: 150 | task = Task.objects.get(id=task_id) 151 | task.completed = not task.completed 152 | task.save() 153 | return True 154 | 155 | except Task.DoesNotExist: 156 | log.info(f"Task {task_id} not found.") 157 | return False 158 | 159 | 160 | def remove_attachment_file(attachment_id: int) -> bool: 161 | """Delete an Attachment object and its corresponding file from the filesystem.""" 162 | try: 163 | attachment = Attachment.objects.get(id=attachment_id) 164 | if attachment.file: 165 | if os.path.isfile(attachment.file.path): 166 | os.remove(attachment.file.path) 167 | 168 | attachment.delete() 169 | return True 170 | 171 | except Attachment.DoesNotExist: 172 | log.info(f"Attachment {attachment_id} not found.") 173 | return False 174 | -------------------------------------------------------------------------------- /todo/mail/consumers/tracker.py: -------------------------------------------------------------------------------- 1 | import re 2 | import logging 3 | 4 | from email.charset import Charset as EMailCharset 5 | from django.db import transaction 6 | from django.db.models import Count 7 | from django.contrib.auth import get_user_model 8 | from django.conf import settings 9 | from html2text import html2text 10 | from email.utils import parseaddr 11 | from todo.models import Comment, Task, TaskList 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | def part_decode(message): 17 | charset = ("ascii", "ignore") 18 | email_charset = message.get_content_charset() 19 | if email_charset: 20 | charset = (EMailCharset(email_charset).input_charset,) 21 | 22 | body = message.get_payload(decode=True) 23 | return body.decode(*charset) 24 | 25 | 26 | def message_find_mime(message, mime_type): 27 | for submessage in message.walk(): 28 | if submessage.get_content_type() == mime_type: 29 | return submessage 30 | return None 31 | 32 | 33 | def message_text(message): 34 | text_part = message_find_mime(message, "text/plain") 35 | if text_part is not None: 36 | return part_decode(text_part) 37 | 38 | html_part = message_find_mime(message, "text/html") 39 | if html_part is not None: 40 | return html2text(part_decode(html_part)) 41 | 42 | # TODO: find something smart to do when no text if found 43 | return "" 44 | 45 | 46 | def format_task_title(format_string, message): 47 | return format_string.format(subject=message["subject"], author=message["from"]) 48 | 49 | 50 | DJANGO_TODO_THREAD = re.compile(r"") 51 | 52 | 53 | def parse_references(task_list, references): 54 | related_messages = [] 55 | answer_thread = None 56 | for related_message in references.split(): 57 | logger.info("checking reference: %r", related_message) 58 | match = re.match(DJANGO_TODO_THREAD, related_message) 59 | if match is None: 60 | related_messages.append(related_message) 61 | continue 62 | 63 | thread_id = int(match.group(1)) 64 | new_answer_thread = Task.objects.filter(task_list=task_list, pk=thread_id).first() 65 | if new_answer_thread is not None: 66 | answer_thread = new_answer_thread 67 | 68 | if answer_thread is None: 69 | logger.info("no answer thread found in references") 70 | else: 71 | logger.info("found an answer thread: %s", str(answer_thread)) 72 | return related_messages, answer_thread 73 | 74 | 75 | def insert_message(task_list, message, priority, task_title_format): 76 | if "message-id" not in message: 77 | logger.warning("missing message id, ignoring message") 78 | return 79 | 80 | if "from" not in message: 81 | logger.warning('missing "From" header, ignoring message') 82 | return 83 | 84 | if "subject" not in message: 85 | logger.warning('missing "Subject" header, ignoring message') 86 | return 87 | 88 | logger.info( 89 | "received message:\t" 90 | f"[Subject: {message['subject']}]\t" 91 | f"[Message-ID: {message['message-id']}]\t" 92 | f"[References: {message['references']}]\t" 93 | f"[To: {message['to']}]\t" 94 | f"[From: {message['from']}]" 95 | ) 96 | 97 | # Due to limitations in MySQL wrt unique_together and TextField (grrr), 98 | # we must use a CharField rather than TextField for message_id. 99 | # In the unlikeley event that we get a VERY long inbound 100 | # message_id, truncate it to the max_length of a MySQL CharField. 101 | original_message_id = message["message-id"] 102 | message_id = ( 103 | (original_message_id[:252] + "...") 104 | if len(original_message_id) > 255 105 | else original_message_id 106 | ) 107 | message_from = message["from"] 108 | text = message_text(message) 109 | 110 | related_messages, answer_thread = parse_references(task_list, message.get("references", "")) 111 | 112 | # find the most relevant task to add a comment on. 113 | # among tasks in the selected task list, find the task having the 114 | # most email comments the current message references 115 | best_task = ( 116 | Task.objects.filter(task_list=task_list, comment__email_message_id__in=related_messages) 117 | .annotate(num_comments=Count("comment")) 118 | .order_by("-num_comments") 119 | .only("id") 120 | .first() 121 | ) 122 | 123 | # if no related comment is found but a thread message-id 124 | # (generated by django-todo) could be found, use it 125 | if best_task is None and answer_thread is not None: 126 | best_task = answer_thread 127 | 128 | with transaction.atomic(): 129 | if best_task is None: 130 | best_task = Task.objects.create( 131 | priority=priority, 132 | title=format_task_title(task_title_format, message), 133 | task_list=task_list, 134 | created_by=match_user(message_from), 135 | ) 136 | logger.info("using task: %r", best_task) 137 | 138 | comment, comment_created = Comment.objects.get_or_create( 139 | task=best_task, 140 | email_message_id=message_id, 141 | defaults={"email_from": message_from, "body": text}, 142 | author=match_user(message_from), # TODO: Write test for this 143 | ) 144 | logger.info("created comment: %r", comment) 145 | 146 | 147 | def tracker_consumer( 148 | producer, group=None, task_list_slug=None, priority=1, task_title_format="[MAIL] {subject}" 149 | ): 150 | task_list = TaskList.objects.get(group__name=group, slug=task_list_slug) 151 | for message in producer: 152 | try: 153 | insert_message(task_list, message, priority, task_title_format) 154 | except Exception: 155 | # ignore exceptions during insertion, in order to avoid 156 | logger.exception("got exception while inserting message") 157 | 158 | 159 | def match_user(email): 160 | """ This function takes an email and checks for a registered user.""" 161 | 162 | if not settings.TODO_MAIL_USER_MAPPER: 163 | user = None 164 | else: 165 | try: 166 | # Find the first user that matches the email 167 | user = get_user_model().objects.get(email=parseaddr(email)[1]) 168 | except get_user_model().DoesNotExist: 169 | user = None 170 | 171 | return user 172 | -------------------------------------------------------------------------------- /todo/models.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import datetime 4 | import os 5 | import textwrap 6 | 7 | from django.conf import settings 8 | from django.contrib.auth.models import Group 9 | from django.db import DEFAULT_DB_ALIAS, models 10 | from django.db.transaction import Atomic, get_connection 11 | from django.urls import reverse 12 | from django.utils import timezone 13 | 14 | 15 | def get_attachment_upload_dir(instance, filename): 16 | """Determine upload dir for task attachment files. 17 | """ 18 | 19 | return "/".join(["tasks", "attachments", str(instance.task.id), filename]) 20 | 21 | 22 | class LockedAtomicTransaction(Atomic): 23 | """ 24 | modified from https://stackoverflow.com/a/41831049 25 | this is needed for safely merging 26 | 27 | Does a atomic transaction, but also locks the entire table for any transactions, for the duration of this 28 | transaction. Although this is the only way to avoid concurrency issues in certain situations, it should be used with 29 | caution, since it has impacts on performance, for obvious reasons... 30 | """ 31 | 32 | def __init__(self, *models, using=None, savepoint=None): 33 | if using is None: 34 | using = DEFAULT_DB_ALIAS 35 | super().__init__(using, savepoint) 36 | self.models = models 37 | 38 | def __enter__(self): 39 | super(LockedAtomicTransaction, self).__enter__() 40 | 41 | # Make sure not to lock, when sqlite is used, or you'll run into problems while running tests!!! 42 | if settings.DATABASES[self.using]["ENGINE"] != "django.db.backends.sqlite3": 43 | cursor = None 44 | try: 45 | cursor = get_connection(self.using).cursor() 46 | for model in self.models: 47 | cursor.execute( 48 | "LOCK TABLE {table_name}".format(table_name=model._meta.db_table) 49 | ) 50 | finally: 51 | if cursor and not cursor.closed: 52 | cursor.close() 53 | 54 | 55 | class TaskList(models.Model): 56 | name = models.CharField(max_length=60) 57 | slug = models.SlugField(default="") 58 | group = models.ForeignKey(Group, on_delete=models.CASCADE) 59 | 60 | def __str__(self): 61 | return self.name 62 | 63 | class Meta: 64 | ordering = ["name"] 65 | verbose_name_plural = "Task Lists" 66 | 67 | # Prevents (at the database level) creation of two lists with the same slug in the same group 68 | unique_together = ("group", "slug") 69 | 70 | 71 | class Task(models.Model): 72 | title = models.CharField(max_length=140) 73 | task_list = models.ForeignKey(TaskList, on_delete=models.CASCADE, null=True) 74 | created_date = models.DateField(default=timezone.now, blank=True, null=True) 75 | due_date = models.DateField(blank=True, null=True) 76 | completed = models.BooleanField(default=False) 77 | completed_date = models.DateField(blank=True, null=True) 78 | created_by = models.ForeignKey( 79 | settings.AUTH_USER_MODEL, 80 | null=True, 81 | blank=True, 82 | related_name="todo_created_by", 83 | on_delete=models.CASCADE, 84 | ) 85 | assigned_to = models.ForeignKey( 86 | settings.AUTH_USER_MODEL, 87 | blank=True, 88 | null=True, 89 | related_name="todo_assigned_to", 90 | on_delete=models.CASCADE, 91 | ) 92 | note = models.TextField(blank=True, null=True) 93 | priority = models.PositiveIntegerField(blank=True, null=True) 94 | 95 | # Has due date for an instance of this object passed? 96 | def overdue_status(self): 97 | "Returns whether the Tasks's due date has passed or not." 98 | if self.due_date and datetime.date.today() > self.due_date: 99 | return True 100 | 101 | def __str__(self): 102 | return self.title 103 | 104 | def get_absolute_url(self): 105 | return reverse("todo:task_detail", kwargs={"task_id": self.id}) 106 | 107 | # Auto-set the Task creation / completed date 108 | def save(self, **kwargs): 109 | # If Task is being marked complete, set the completed_date 110 | if self.completed: 111 | self.completed_date = datetime.datetime.now() 112 | super(Task, self).save() 113 | 114 | def merge_into(self, merge_target): 115 | if merge_target.pk == self.pk: 116 | raise ValueError("can't merge a task with self") 117 | 118 | # lock the comments to avoid concurrent additions of comments after the 119 | # update request. these comments would be irremediably lost because of 120 | # the cascade clause 121 | with LockedAtomicTransaction(Comment): 122 | Comment.objects.filter(task=self).update(task=merge_target) 123 | self.delete() 124 | 125 | class Meta: 126 | ordering = ["priority", "created_date"] 127 | 128 | 129 | class Comment(models.Model): 130 | """ 131 | Not using Django's built-in comments because we want to be able to save 132 | a comment and change task details at the same time. Rolling our own since it's easy. 133 | """ 134 | 135 | author = models.ForeignKey( 136 | settings.AUTH_USER_MODEL, on_delete=models.CASCADE, blank=True, null=True, 137 | related_name="todo_comments" 138 | ) 139 | task = models.ForeignKey(Task, on_delete=models.CASCADE) 140 | date = models.DateTimeField(default=datetime.datetime.now) 141 | email_from = models.CharField(max_length=320, blank=True, null=True) 142 | email_message_id = models.CharField(max_length=255, blank=True, null=True) 143 | 144 | body = models.TextField(blank=True) 145 | 146 | class Meta: 147 | # an email should only appear once per task 148 | unique_together = ("task", "email_message_id") 149 | 150 | @property 151 | def author_text(self): 152 | if self.author is not None: 153 | return str(self.author) 154 | 155 | assert self.email_message_id is not None 156 | return str(self.email_from) 157 | 158 | @property 159 | def snippet(self): 160 | body_snippet = textwrap.shorten(self.body, width=35, placeholder="...") 161 | # Define here rather than in __str__ so we can use it in the admin list_display 162 | return "{author} - {snippet}...".format(author=self.author_text, snippet=body_snippet) 163 | 164 | def __str__(self): 165 | return self.snippet 166 | 167 | 168 | class Attachment(models.Model): 169 | """ 170 | Defines a generic file attachment for use in M2M relation with Task. 171 | """ 172 | 173 | task = models.ForeignKey(Task, on_delete=models.CASCADE) 174 | added_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) 175 | timestamp = models.DateTimeField(default=datetime.datetime.now) 176 | file = models.FileField(upload_to=get_attachment_upload_dir, max_length=255) 177 | 178 | def filename(self): 179 | return os.path.basename(self.file.name) 180 | 181 | def extension(self): 182 | name, extension = os.path.splitext(self.file.name) 183 | return extension 184 | 185 | def __str__(self): 186 | return f"{self.task.id} - {self.file.name}" 187 | -------------------------------------------------------------------------------- /todo/templates/todo/task_detail.html: -------------------------------------------------------------------------------- 1 | {% extends "todo/base.html" %} 2 | 3 | {% block title %}Task:{{ task.title }}{% endblock %} 4 | 5 | {% block extrahead %} 6 | 15 | {{ form.media }} 16 | {{ merge_form.media }} 17 | {% endblock %} 18 | 19 | 20 | 21 | {% block content %} 22 |
23 |
24 |
25 |

{{ task.title }}

26 | {% if task.note %} 27 |
{{ task.note|safe|urlize|linebreaks }}
28 | {% endif %} 29 |
30 |
31 | 32 |
33 |
    34 |
  • 35 | 43 | 44 |
    45 | {% csrf_token %} 46 |
    47 | 50 |
    51 |
    52 | 53 |
    54 | {% csrf_token %} 55 |
    56 | 59 |
    60 |
    61 |
  • 62 |
  • 63 | Assigned to: 64 | {% if task.assigned_to %} {{ task.assigned_to.get_full_name }} {% else %} Anyone {% endif %} 65 |
  • 66 |
  • 67 | Reported by: {{ task.created_by.get_full_name }} 68 |
  • 69 |
  • 70 | Due date: {{ task.due_date }} 71 |
  • 72 | 73 | {% if task.completed %} 74 |
  • 75 | Completed on: {{ task.completed_date}} 76 |
  • 77 | {% else %} 78 |
  • 79 | Completed: {{ task.completed|yesno:"Yes,No" }} 80 |
  • 81 | {% endif %} 82 | 83 |
  • 84 | In list: 85 | 86 | {{ task.task_list }} 87 | 88 |
  • 89 |
90 |
91 |
92 | 93 |
94 | {# Task edit / new task form #} 95 | {% include 'todo/include/task_edit.html' %} 96 | {% if merge_form is not None %} 97 |
98 |
99 |
Merge task
100 |
101 |
102 |

Merging is a destructive operation. This task will not exist anymore, and comments will be moved to the target task.

103 | {% csrf_token %} 104 | {% for field in merge_form.visible_fields %} 105 |

106 | {{ field.errors }} 107 | {{ field }} 108 |

109 | {% endfor %} 110 | 111 |
112 |
113 |
114 |
115 | {% endif %} 116 |
117 | 118 | {% if attachments_enabled %} 119 |
120 |
121 | Attachments 122 |
123 | 124 |
125 | {% if task.attachment_set.count %} 126 |
127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | {% for attachment in task.attachment_set.all %} 139 | 140 | 141 | 142 | 143 | 144 | 150 | 151 | {% endfor %} 152 | 153 |
FileUploadedByTypeRemove
{{ attachment.filename }}{{ attachment.timestamp }}{{ attachment.added_by.get_full_name }}{{ attachment.extension.lower }} 145 |
146 | {% csrf_token %} 147 | 148 |
149 |
154 |
155 | {% endif %} 156 | 157 |
158 | {% csrf_token %} 159 |
160 |
161 | 162 | 163 |
164 |
165 | 166 |
167 |
168 |
169 | 170 |
171 |
172 | {% endif %} 173 | 174 |
175 |
Add comment
176 |
177 | {% csrf_token %} 178 |
179 | 180 |
181 | 182 |
183 |
184 | 185 |
186 | {% if comment_list %} 187 |
Comments on this task
188 | {% for comment in comment_list %} 189 |
190 |
191 |
192 | {% if comment.email_message_id %} 193 | email 194 | {% endif %} 195 | {{ comment.author_text }} 196 |
197 | 198 | {{ comment.date|date:"F d Y P" }} 199 | 200 |
201 |
202 | {{ comment.body|safe|urlize|linebreaks }} 203 |
204 |
205 | {% endfor %} 206 | {% else %} 207 |
No comments (yet).
208 | {% endif %} 209 |
210 | {% endblock %} 211 | 212 | {% block extra_js %} 213 | {# Support file attachment uploader #} 214 | 222 | {% endblock extra_js %} 223 | 224 | -------------------------------------------------------------------------------- /todo/operations/csv_importer.py: -------------------------------------------------------------------------------- 1 | import codecs 2 | import csv 3 | import datetime 4 | import logging 5 | 6 | from django.contrib.auth import get_user_model 7 | from django.contrib.auth.models import Group 8 | 9 | from todo.models import Task, TaskList 10 | 11 | log = logging.getLogger(__name__) 12 | 13 | 14 | class CSVImporter: 15 | """Core upsert functionality for CSV import, for re-use by `import_csv` management command, web UI and tests. 16 | Supplies a detailed log of what was and was not imported at the end. See README for usage notes. 17 | """ 18 | 19 | def __init__(self): 20 | self.errors = [] 21 | self.upserts = [] 22 | self.summaries = [] 23 | self.line_count = 0 24 | self.upsert_count = 0 25 | 26 | def upsert(self, fileobj, as_string_obj=False): 27 | """Expects a file *object*, not a file path. This is important because this has to work for both 28 | the management command and the web uploader; the web uploader will pass in in-memory file 29 | with no path! 30 | 31 | Header row is: 32 | Title, Group, Task List, Created Date, Due Date, Completed, Created By, Assigned To, Note, Priority 33 | """ 34 | 35 | if as_string_obj: 36 | # fileobj comes from mgmt command 37 | csv_reader = csv.DictReader(fileobj) 38 | else: 39 | # fileobj comes from browser upload (in-memory) 40 | csv_reader = csv.DictReader(codecs.iterdecode(fileobj, "utf-8")) 41 | 42 | # DI check: Do we have expected header row? 43 | header = csv_reader.fieldnames 44 | expected = [ 45 | "Title", 46 | "Group", 47 | "Task List", 48 | "Created By", 49 | "Created Date", 50 | "Due Date", 51 | "Completed", 52 | "Assigned To", 53 | "Note", 54 | "Priority", 55 | ] 56 | if header != expected: 57 | self.errors.append( 58 | f"Inbound data does not have expected columns.\nShould be: {expected}" 59 | ) 60 | return 61 | 62 | for row in csv_reader: 63 | self.line_count += 1 64 | 65 | newrow = self.validate_row(row) 66 | if newrow: 67 | # newrow at this point is fully validated, and all FK relations exist, 68 | # e.g. `newrow.get("Assigned To")`, is a Django User instance. 69 | assignee = newrow.get("Assigned To") if newrow.get("Assigned To") else None 70 | created_date = ( 71 | newrow.get("Created Date") 72 | if newrow.get("Created Date") 73 | else datetime.datetime.today() 74 | ) 75 | due_date = newrow.get("Due Date") if newrow.get("Due Date") else None 76 | priority = newrow.get("Priority") if newrow.get("Priority") else None 77 | 78 | obj, created = Task.objects.update_or_create( 79 | created_by=newrow.get("Created By"), 80 | task_list=newrow.get("Task List"), 81 | title=newrow.get("Title"), 82 | defaults={ 83 | "assigned_to": assignee, 84 | "completed": newrow.get("Completed"), 85 | "created_date": created_date, 86 | "due_date": due_date, 87 | "note": newrow.get("Note"), 88 | "priority": priority, 89 | }, 90 | ) 91 | self.upsert_count += 1 92 | msg = ( 93 | f'Upserted task {obj.id}: "{obj.title}"' 94 | f' in list "{obj.task_list}" (group "{obj.task_list.group}")' 95 | ) 96 | self.upserts.append(msg) 97 | 98 | self.summaries.append(f"Processed {self.line_count} CSV rows") 99 | self.summaries.append(f"Upserted {self.upsert_count} rows") 100 | self.summaries.append(f"Skipped {self.line_count - self.upsert_count} rows") 101 | 102 | return {"summaries": self.summaries, "upserts": self.upserts, "errors": self.errors} 103 | 104 | def validate_row(self, row): 105 | """Perform data integrity checks and set default values. Returns a valid object for insertion, or False. 106 | Errors are stored for later display. Intentionally not broken up into separate validator functions because 107 | there are interdpendencies, such as checking for existing `creator` in one place and then using 108 | that creator for group membership check in others.""" 109 | 110 | row_errors = [] 111 | 112 | # ####################### 113 | # Task creator must exist 114 | if not row.get("Created By"): 115 | msg = f"Missing required task creator." 116 | row_errors.append(msg) 117 | 118 | creator = get_user_model().objects.filter(username=row.get("Created By")).first() 119 | if not creator: 120 | msg = f"Invalid task creator {row.get('Created By')}" 121 | row_errors.append(msg) 122 | 123 | # ####################### 124 | # If specified, Assignee must exist 125 | assignee = None # Perfectly valid 126 | if row.get("Assigned To"): 127 | assigned = get_user_model().objects.filter(username=row.get("Assigned To")) 128 | if assigned.exists(): 129 | assignee = assigned.first() 130 | else: 131 | msg = f"Missing or invalid task assignee {row.get('Assigned To')}" 132 | row_errors.append(msg) 133 | 134 | # ####################### 135 | # Group must exist 136 | try: 137 | target_group = Group.objects.get(name=row.get("Group")) 138 | except Group.DoesNotExist: 139 | msg = f"Could not find group {row.get('Group')}." 140 | row_errors.append(msg) 141 | target_group = None 142 | 143 | # ####################### 144 | # Task creator must be in the target group 145 | if creator and target_group not in creator.groups.all(): 146 | msg = f"{creator} is not in group {target_group}" 147 | row_errors.append(msg) 148 | 149 | # ####################### 150 | # Assignee must be in the target group 151 | if assignee and target_group not in assignee.groups.all(): 152 | msg = f"{assignee} is not in group {target_group}" 153 | row_errors.append(msg) 154 | 155 | # ####################### 156 | # Task list must exist in the target group 157 | try: 158 | tasklist = TaskList.objects.get(name=row.get("Task List"), group=target_group) 159 | row["Task List"] = tasklist 160 | except TaskList.DoesNotExist: 161 | msg = f"Task list {row.get('Task List')} in group {target_group} does not exist" 162 | row_errors.append(msg) 163 | 164 | # ####################### 165 | # Validate Dates 166 | datefields = ["Due Date", "Created Date"] 167 | for datefield in datefields: 168 | datestring = row.get(datefield) 169 | if datestring: 170 | valid_date = self.validate_date(datestring) 171 | if valid_date: 172 | row[datefield] = valid_date 173 | else: 174 | msg = f"Could not convert {datefield} {datestring} to valid date instance" 175 | row_errors.append(msg) 176 | 177 | # ####################### 178 | # Group membership checks have passed 179 | row["Created By"] = creator 180 | row["Group"] = target_group 181 | if assignee: 182 | row["Assigned To"] = assignee 183 | 184 | # Set Completed 185 | row["Completed"] = row["Completed"] == "Yes" 186 | 187 | # ####################### 188 | if row_errors: 189 | self.errors.append({self.line_count: row_errors}) 190 | return False 191 | 192 | # No errors: 193 | return row 194 | 195 | def validate_date(self, datestring): 196 | """Inbound date string from CSV translates to a valid python date.""" 197 | try: 198 | date_obj = datetime.datetime.strptime(datestring, "%Y-%m-%d") 199 | return date_obj 200 | except ValueError: 201 | return False 202 | -------------------------------------------------------------------------------- /todo/tests/test_views.py: -------------------------------------------------------------------------------- 1 | import bleach 2 | import pytest 3 | 4 | from django.contrib.auth import get_user_model 5 | from django.contrib.auth.models import Group 6 | from django.urls import reverse 7 | 8 | from todo.models import Task, TaskList 9 | 10 | """ 11 | First the "smoketests" - do they respond at all for a logged in admin user? 12 | Next permissions tests - some views should respond for staffers only. 13 | After that, view contents and behaviors. 14 | """ 15 | 16 | 17 | @pytest.mark.django_db 18 | def test_todo_setup(todo_setup): 19 | assert Task.objects.all().count() == 6 20 | 21 | 22 | def test_view_list_lists(todo_setup, admin_client): 23 | url = reverse("todo:lists") 24 | response = admin_client.get(url) 25 | assert response.status_code == 200 26 | 27 | 28 | def test_view_reorder(todo_setup, admin_client): 29 | url = reverse("todo:reorder_tasks") 30 | response = admin_client.get(url) 31 | assert response.status_code == 201 # Special case return value expected 32 | 33 | 34 | def test_view_external_add(todo_setup, admin_client, settings): 35 | default_list = TaskList.objects.first() 36 | settings.TODO_DEFAULT_LIST_SLUG = default_list.slug 37 | assert settings.TODO_DEFAULT_LIST_SLUG == default_list.slug 38 | url = reverse("todo:external_add") 39 | response = admin_client.get(url) 40 | assert response.status_code == 200 41 | 42 | 43 | def test_view_mine(todo_setup, admin_client): 44 | url = reverse("todo:mine") 45 | response = admin_client.get(url) 46 | assert response.status_code == 200 47 | 48 | 49 | def test_view_list_completed(todo_setup, admin_client): 50 | tlist = TaskList.objects.get(slug="zip") 51 | url = reverse( 52 | "todo:list_detail_completed", kwargs={"list_id": tlist.id, "list_slug": tlist.slug} 53 | ) 54 | response = admin_client.get(url) 55 | assert response.status_code == 200 56 | 57 | 58 | def test_view_list(todo_setup, admin_client): 59 | tlist = TaskList.objects.get(slug="zip") 60 | url = reverse("todo:list_detail", kwargs={"list_id": tlist.id, "list_slug": tlist.slug}) 61 | response = admin_client.get(url) 62 | assert response.status_code == 200 63 | 64 | 65 | def test_view_add_list(todo_setup, admin_client): 66 | url = reverse("todo:add_list") 67 | response = admin_client.get(url) 68 | assert response.status_code == 200 69 | 70 | 71 | def test_view_task_detail(todo_setup, admin_client): 72 | task = Task.objects.first() 73 | url = reverse("todo:task_detail", kwargs={"task_id": task.id}) 74 | response = admin_client.get(url) 75 | assert response.status_code == 200 76 | 77 | 78 | def test_del_task(todo_setup, admin_user, client): 79 | task = Task.objects.first() 80 | url = reverse("todo:delete_task", kwargs={"task_id": task.id}) 81 | # View accepts POST, not GET 82 | client.login(username="admin", password="password") 83 | response = client.get(url) 84 | assert response.status_code == 403 85 | response = client.post(url) 86 | assert not Task.objects.filter(id=task.id).exists() 87 | 88 | 89 | def test_task_toggle_done(todo_setup, admin_user, client): 90 | task = Task.objects.first() 91 | assert not task.completed 92 | url = reverse("todo:task_toggle_done", kwargs={"task_id": task.id}) 93 | # View accepts POST, not GET 94 | client.login(username="admin", password="password") 95 | response = client.get(url) 96 | assert response.status_code == 403 97 | 98 | client.post(url) 99 | task.refresh_from_db() 100 | assert task.completed 101 | 102 | 103 | def test_view_search(todo_setup, admin_client): 104 | url = reverse("todo:search") 105 | response = admin_client.get(url) 106 | assert response.status_code == 200 107 | 108 | 109 | @pytest.mark.django_db 110 | def test_no_javascript_in_task_note(todo_setup, client): 111 | task_list = TaskList.objects.first() 112 | user = get_user_model().objects.get(username="u2") 113 | title = "Some Unique String" 114 | note = "foo bar" 115 | data = { 116 | "task_list": task_list.id, 117 | "created_by": user.id, 118 | "priority": 10, 119 | "title": title, 120 | "note": note, 121 | "add_edit_task": "Submit", 122 | } 123 | 124 | client.login(username="u2", password="password") 125 | url = reverse("todo:list_detail", kwargs={"list_id": task_list.id, "list_slug": task_list.slug}) 126 | 127 | response = client.post(url, data) 128 | assert response.status_code == 302 129 | 130 | # Retrieve new task and compare notes field 131 | task = Task.objects.get(title=title) 132 | assert task.note != note # Should have been modified by bleach since note included javascript! 133 | assert task.note == bleach.clean(note, strip=True) 134 | 135 | 136 | @pytest.mark.django_db 137 | def test_created_by_unchanged(todo_setup, client): 138 | 139 | task_list = TaskList.objects.first() 140 | u2 = get_user_model().objects.get(username="u2") 141 | title = "Some Unique String with unique chars: ab78539e" 142 | note = "a note" 143 | data = { 144 | "task_list": task_list.id, 145 | "created_by": u2.id, 146 | "priority": 10, 147 | "title": title, 148 | "note": note, 149 | "add_edit_task": "Submit", 150 | } 151 | 152 | client.login(username="u2", password="password") 153 | url_add_task = reverse( 154 | "todo:list_detail", kwargs={"list_id": task_list.id, "list_slug": task_list.slug} 155 | ) 156 | 157 | response = client.post(url_add_task, data) 158 | assert response.status_code == 302 159 | 160 | # Retrieve new task and compare created_by 161 | task = Task.objects.get(title=title) 162 | assert task.created_by == u2 163 | 164 | # Now that we've created the task, edit it as another user. 165 | # After saving, created_by should remain unchanged. 166 | extra_g2_user = get_user_model().objects.get(username="extra_g2_user") 167 | 168 | client.login(username="extra_g2_user", password="password") 169 | 170 | url_edit_task = reverse("todo:task_detail", kwargs={"task_id": task.id}) 171 | 172 | dataTwo = { 173 | "task_list": task.task_list.id, 174 | "created_by": extra_g2_user.id, # this submission is attempting to change created_by 175 | "priority": 10, 176 | "title": task.title, 177 | "note": "the note was changed", 178 | "add_edit_task": "Submit", 179 | } 180 | 181 | response = client.post(url_edit_task, dataTwo) 182 | assert response.status_code == 302 183 | 184 | task.refresh_from_db() 185 | 186 | # Proof that the task was saved: 187 | assert task.note == "the note was changed" 188 | 189 | # client was unable to modify created_by: 190 | assert task.created_by == u2 191 | 192 | 193 | @pytest.mark.django_db 194 | @pytest.mark.parametrize("test_input, expected", [(True, True), (False, False)]) 195 | def test_completed_unchanged(test_input, expected, todo_setup, client): 196 | """Tasks are marked completed/uncompleted by buttons, 197 | not via checkbox on the task edit form. Editing a task should 198 | not change its completed status. Test with both completed and incomplete Tasks.""" 199 | 200 | task = Task.objects.get(title="Task 1", created_by__username="u1") 201 | task.completed = test_input 202 | task.save() 203 | assert task.completed == expected 204 | 205 | url_edit_task = reverse("todo:task_detail", kwargs={"task_id": task.id}) 206 | 207 | data = { 208 | "task_list": task.task_list.id, 209 | "title": "Something", 210 | "note": "the note was changed", 211 | "add_edit_task": "Submit", 212 | "completed": task.completed, 213 | } 214 | 215 | client.login(username="u1", password="password") 216 | response = client.post(url_edit_task, data) 217 | assert response.status_code == 302 218 | 219 | # Prove the task is still marked complete/incomplete 220 | # (despite the default default state for completed being False) 221 | task.refresh_from_db() 222 | assert task.completed == expected 223 | 224 | 225 | @pytest.mark.django_db 226 | def test_no_javascript_in_comments(todo_setup, client): 227 | user = get_user_model().objects.get(username="u2") 228 | client.login(username="u2", password="password") 229 | 230 | task = Task.objects.first() 231 | task.created_by = user 232 | task.save() 233 | 234 | user.groups.add(task.task_list.group) 235 | 236 | comment = "foo bar" 237 | data = {"comment-body": comment, "add_comment": "Submit"} 238 | url = reverse("todo:task_detail", kwargs={"task_id": task.id}) 239 | 240 | response = client.post(url, data) 241 | assert response.status_code == 200 242 | 243 | task.refresh_from_db() 244 | newcomment = task.comment_set.last() 245 | assert newcomment != comment # Should have been modified by bleach 246 | assert newcomment.body == bleach.clean(comment, strip=True) 247 | 248 | 249 | # ### PERMISSIONS ### 250 | 251 | 252 | def test_view_add_list_nonadmin(todo_setup, client): 253 | url = reverse("todo:add_list") 254 | client.login(username="you", password="password") 255 | response = client.get(url) 256 | assert response.status_code == 302 # Redirected to login 257 | 258 | 259 | def test_view_del_list_nonadmin(todo_setup, client): 260 | tlist = TaskList.objects.get(slug="zip") 261 | url = reverse("todo:del_list", kwargs={"list_id": tlist.id, "list_slug": tlist.slug}) 262 | client.login(username="you", password="password") 263 | response = client.get(url) 264 | assert response.status_code == 302 # Fedirected to login 265 | 266 | 267 | def test_del_list_not_in_list_group(todo_setup, admin_client): 268 | tlist = TaskList.objects.get(slug="zip") 269 | url = reverse("todo:del_list", kwargs={"list_id": tlist.id, "list_slug": tlist.slug}) 270 | response = admin_client.get(url) 271 | assert response.status_code == 403 272 | 273 | 274 | def test_view_list_mine(todo_setup, client): 275 | """View a list in a group I belong to. 276 | """ 277 | tlist = TaskList.objects.get(slug="zip") # User u1 is in this group's list 278 | url = reverse("todo:list_detail", kwargs={"list_id": tlist.id, "list_slug": tlist.slug}) 279 | client.login(username="u1", password="password") 280 | response = client.get(url) 281 | assert response.status_code == 200 282 | 283 | 284 | def test_view_list_not_mine(todo_setup, client): 285 | """View a list in a group I don't belong to. 286 | """ 287 | tlist = TaskList.objects.get(slug="zip") # User u1 is in this group, user u2 is not. 288 | url = reverse("todo:list_detail", kwargs={"list_id": tlist.id, "list_slug": tlist.slug}) 289 | client.login(username="u2", password="password") 290 | response = client.get(url) 291 | assert response.status_code == 403 292 | 293 | 294 | def test_view_task_mine(todo_setup, client): 295 | # Users can always view their own tasks 296 | task = Task.objects.filter(created_by__username="u1").first() 297 | client.login(username="u1", password="password") 298 | url = reverse("todo:task_detail", kwargs={"task_id": task.id}) 299 | response = client.get(url) 300 | assert response.status_code == 200 301 | 302 | 303 | def test_view_task_my_group(todo_setup, client, django_user_model): 304 | """User can always view tasks that are NOT theirs IF the task is in a shared group. 305 | u1 and u2 are in different groups in the fixture - 306 | Put them in the same group.""" 307 | g1 = Group.objects.get(name="Workgroup One") 308 | u2 = django_user_model.objects.get(username="u2") 309 | u2.groups.add(g1) 310 | 311 | # Now u2 should be able to view one of u1's tasks. 312 | task = Task.objects.filter(created_by__username="u1").first() 313 | url = reverse("todo:task_detail", kwargs={"task_id": task.id}) 314 | client.login(username="u2", password="password") 315 | response = client.get(url) 316 | assert response.status_code == 200 317 | 318 | 319 | def test_view_task_not_in_my_group(todo_setup, client): 320 | # User canNOT view a task that isn't theirs if the two users are not in a shared group. 321 | # For this we can use the fixture data as-is. 322 | task = Task.objects.filter(created_by__username="u1").first() 323 | url = reverse("todo:task_detail", kwargs={"task_id": task.id}) 324 | client.login(username="u2", password="password") 325 | response = client.get(url) 326 | assert response.status_code == 403 327 | 328 | 329 | def test_setting_TODO_STAFF_ONLY_False(todo_setup, client, settings): 330 | # We use Django's user_passes_test to call `staff_check` utility function on all views. 331 | # Just testing one view here; if it works, it works for all of them. 332 | settings.TODO_STAFF_ONLY = False 333 | url = reverse("todo:lists") 334 | client.login(username="u2", password="password") 335 | response = client.get(url) 336 | assert response.status_code == 200 337 | 338 | 339 | def test_setting_TODO_STAFF_ONLY_True(todo_setup, client, settings, django_user_model): 340 | # We use Django's user_passes_test to call `staff_check` utility function on some views. 341 | # Just testing one view here... 342 | settings.TODO_STAFF_ONLY = True 343 | url = reverse("todo:lists") 344 | 345 | # Remove staff privileges from user u2; they should not be able to access 346 | u2 = django_user_model.objects.get(username="u2") 347 | u2.is_staff = False 348 | u2.save() 349 | 350 | client.login(username="u2", password="password") 351 | response = client.get(url) 352 | assert response.status_code == 302 # Redirected to login view 353 | -------------------------------------------------------------------------------- /todo/static/todo/js/jquery.tablednd_0_5.js: -------------------------------------------------------------------------------- 1 | /** 2 | * TableDnD plug-in for JQuery, allows you to drag and drop table rows 3 | * You can set up various options to control how the system will work 4 | * Copyright (c) Denis Howlett 5 | * Licensed like jQuery, see http://docs.jquery.com/License. 6 | * 7 | * Configuration options: 8 | * 9 | * onDragStyle 10 | * This is the style that is assigned to the row during drag. There are limitations to the styles that can be 11 | * associated with a row (such as you can't assign a border--well you can, but it won't be 12 | * displayed). (So instead consider using onDragClass.) The CSS style to apply is specified as 13 | * a map (as used in the jQuery css(...) function). 14 | * onDropStyle 15 | * This is the style that is assigned to the row when it is dropped. As for onDragStyle, there are limitations 16 | * to what you can do. Also this replaces the original style, so again consider using onDragClass which 17 | * is simply added and then removed on drop. 18 | * onDragClass 19 | * This class is added for the duration of the drag and then removed when the row is dropped. It is more 20 | * flexible than using onDragStyle since it can be inherited by the row cells and other content. The default 21 | * is class is tDnD_whileDrag. So to use the default, simply customise this CSS class in your 22 | * stylesheet. 23 | * onDrop 24 | * Pass a function that will be called when the row is dropped. The function takes 2 parameters: the table 25 | * and the row that was dropped. You can work out the new order of the rows by using 26 | * table.rows. 27 | * onDragStart 28 | * Pass a function that will be called when the user starts dragging. The function takes 2 parameters: the 29 | * table and the row which the user has started to drag. 30 | * onAllowDrop 31 | * Pass a function that will be called as a row is over another row. If the function returns true, allow 32 | * dropping on that row, otherwise not. The function takes 2 parameters: the dragged row and the row under 33 | * the cursor. It returns a boolean: true allows the drop, false doesn't allow it. 34 | * scrollAmount 35 | * This is the number of pixels to scroll if the user moves the mouse cursor to the top or bottom of the 36 | * window. The page should automatically scroll up or down as appropriate (tested in IE6, IE7, Safari, FF2, 37 | * FF3 beta 38 | * dragHandle 39 | * This is the name of a class that you assign to one or more cells in each row that is draggable. If you 40 | * specify this class, then you are responsible for setting cursor: move in the CSS and only these cells 41 | * will have the drag behaviour. If you do not specify a dragHandle, then you get the old behaviour where 42 | * the whole row is draggable. 43 | * 44 | * Other ways to control behaviour: 45 | * 46 | * Add class="nodrop" to any rows for which you don't want to allow dropping, and class="nodrag" to any rows 47 | * that you don't want to be draggable. 48 | * 49 | * Inside the onDrop method you can also call $.tableDnD.serialize() this returns a string of the form 50 | * []=&[]= so that you can send this back to the server. The table must have 51 | * an ID as must all the rows. 52 | * 53 | * Other methods: 54 | * 55 | * $("...").tableDnDUpdate() 56 | * Will update all the matching tables, that is it will reapply the mousedown method to the rows (or handle cells). 57 | * This is useful if you have updated the table rows using Ajax and you want to make the table draggable again. 58 | * The table maintains the original configuration (so you don't have to specify it again). 59 | * 60 | * $("...").tableDnDSerialize() 61 | * Will serialize and return the serialized string as above, but for each of the matching tables--so it can be 62 | * called from anywhere and isn't dependent on the currentTable being set up correctly before calling 63 | * 64 | * Known problems: 65 | * - Auto-scoll has some problems with IE7 (it scrolls even when it shouldn't), work-around: set scrollAmount to 0 66 | * 67 | * Version 0.2: 2008-02-20 First public version 68 | * Version 0.3: 2008-02-07 Added onDragStart option 69 | * Made the scroll amount configurable (default is 5 as before) 70 | * Version 0.4: 2008-03-15 Changed the noDrag/noDrop attributes to nodrag/nodrop classes 71 | * Added onAllowDrop to control dropping 72 | * Fixed a bug which meant that you couldn't set the scroll amount in both directions 73 | * Added serialize method 74 | * Version 0.5: 2008-05-16 Changed so that if you specify a dragHandle class it doesn't make the whole row 75 | * draggable 76 | * Improved the serialize method to use a default (and settable) regular expression. 77 | * Added tableDnDupate() and tableDnDSerialize() to be called when you are outside the table 78 | */ 79 | jQuery.tableDnD = { 80 | /** Keep hold of the current table being dragged */ 81 | currentTable : null, 82 | /** Keep hold of the current drag object if any */ 83 | dragObject: null, 84 | /** The current mouse offset */ 85 | mouseOffset: null, 86 | /** Remember the old value of Y so that we don't do too much processing */ 87 | oldY: 0, 88 | 89 | /** Actually build the structure */ 90 | build: function(options) { 91 | // Set up the defaults if any 92 | 93 | this.each(function() { 94 | // This is bound to each matching table, set up the defaults and override with user options 95 | this.tableDnDConfig = jQuery.extend({ 96 | onDragStyle: null, 97 | onDropStyle: null, 98 | // Add in the default class for whileDragging 99 | onDragClass: "tDnD_whileDrag", 100 | onDrop: null, 101 | onDragStart: null, 102 | scrollAmount: 5, 103 | serializeRegexp: /[^\-]*$/, // The regular expression to use to trim row IDs 104 | serializeParamName: null, // If you want to specify another parameter name instead of the table ID 105 | dragHandle: null // If you give the name of a class here, then only Cells with this class will be draggable 106 | }, options || {}); 107 | // Now make the rows draggable 108 | jQuery.tableDnD.makeDraggable(this); 109 | }); 110 | 111 | // Now we need to capture the mouse up and mouse move event 112 | // We can use bind so that we don't interfere with other event handlers 113 | jQuery(document) 114 | .bind('mousemove', jQuery.tableDnD.mousemove) 115 | .bind('mouseup', jQuery.tableDnD.mouseup); 116 | 117 | // Don't break the chain 118 | return this; 119 | }, 120 | 121 | /** This function makes all the rows on the table draggable apart from those marked as "NoDrag" */ 122 | makeDraggable: function(table) { 123 | var config = table.tableDnDConfig; 124 | if (table.tableDnDConfig.dragHandle) { 125 | // We only need to add the event to the specified cells 126 | var cells = jQuery("td."+table.tableDnDConfig.dragHandle, table); 127 | cells.each(function() { 128 | // The cell is bound to "this" 129 | jQuery(this).mousedown(function(ev) { 130 | jQuery.tableDnD.dragObject = this.parentNode; 131 | jQuery.tableDnD.currentTable = table; 132 | jQuery.tableDnD.mouseOffset = jQuery.tableDnD.getMouseOffset(this, ev); 133 | if (config.onDragStart) { 134 | // Call the onDrop method if there is one 135 | config.onDragStart(table, this); 136 | } 137 | return false; 138 | }); 139 | }) 140 | } else { 141 | // For backwards compatibility, we add the event to the whole row 142 | var rows = jQuery("tr", table); // get all the rows as a wrapped set 143 | rows.each(function() { 144 | // Iterate through each row, the row is bound to "this" 145 | var row = jQuery(this); 146 | if (! row.hasClass("nodrag")) { 147 | row.mousedown(function(ev) { 148 | if (ev.target.tagName == "TD") { 149 | jQuery.tableDnD.dragObject = this; 150 | jQuery.tableDnD.currentTable = table; 151 | jQuery.tableDnD.mouseOffset = jQuery.tableDnD.getMouseOffset(this, ev); 152 | if (config.onDragStart) { 153 | // Call the onDrop method if there is one 154 | config.onDragStart(table, this); 155 | } 156 | return false; 157 | } 158 | }).css("cursor", "move"); // Store the tableDnD object 159 | } 160 | }); 161 | } 162 | }, 163 | 164 | updateTables: function() { 165 | this.each(function() { 166 | // this is now bound to each matching table 167 | if (this.tableDnDConfig) { 168 | jQuery.tableDnD.makeDraggable(this); 169 | } 170 | }) 171 | }, 172 | 173 | /** Get the mouse coordinates from the event (allowing for browser differences) */ 174 | mouseCoords: function(ev){ 175 | if(ev.pageX || ev.pageY){ 176 | return {x:ev.pageX, y:ev.pageY}; 177 | } 178 | return { 179 | x:ev.clientX + document.body.scrollLeft - document.body.clientLeft, 180 | y:ev.clientY + document.body.scrollTop - document.body.clientTop 181 | }; 182 | }, 183 | 184 | /** Given a target element and a mouse event, get the mouse offset from that element. 185 | To do this we need the element's position and the mouse position */ 186 | getMouseOffset: function(target, ev) { 187 | ev = ev || window.event; 188 | 189 | var docPos = this.getPosition(target); 190 | var mousePos = this.mouseCoords(ev); 191 | return {x:mousePos.x - docPos.x, y:mousePos.y - docPos.y}; 192 | }, 193 | 194 | /** Get the position of an element by going up the DOM tree and adding up all the offsets */ 195 | getPosition: function(e){ 196 | var left = 0; 197 | var top = 0; 198 | /** Safari fix -- thanks to Luis Chato for this! */ 199 | if (e.offsetHeight == 0) { 200 | /** Safari 2 doesn't correctly grab the offsetTop of a table row 201 | this is detailed here: 202 | http://jacob.peargrove.com/blog/2006/technical/table-row-offsettop-bug-in-safari/ 203 | the solution is likewise noted there, grab the offset of a table cell in the row - the firstChild. 204 | note that firefox will return a text node as a first child, so designing a more thorough 205 | solution may need to take that into account, for now this seems to work in firefox, safari, ie */ 206 | e = e.firstChild; // a table cell 207 | } 208 | 209 | while (e.offsetParent){ 210 | left += e.offsetLeft; 211 | top += e.offsetTop; 212 | e = e.offsetParent; 213 | } 214 | 215 | left += e.offsetLeft; 216 | top += e.offsetTop; 217 | 218 | return {x:left, y:top}; 219 | }, 220 | 221 | mousemove: function(ev) { 222 | if (jQuery.tableDnD.dragObject == null) { 223 | return; 224 | } 225 | 226 | var dragObj = jQuery(jQuery.tableDnD.dragObject); 227 | var config = jQuery.tableDnD.currentTable.tableDnDConfig; 228 | var mousePos = jQuery.tableDnD.mouseCoords(ev); 229 | var y = mousePos.y - jQuery.tableDnD.mouseOffset.y; 230 | //auto scroll the window 231 | var yOffset = window.pageYOffset; 232 | if (document.all) { 233 | // Windows version 234 | //yOffset=document.body.scrollTop; 235 | if (typeof document.compatMode != 'undefined' && 236 | document.compatMode != 'BackCompat') { 237 | yOffset = document.documentElement.scrollTop; 238 | } 239 | else if (typeof document.body != 'undefined') { 240 | yOffset=document.body.scrollTop; 241 | } 242 | 243 | } 244 | 245 | if (mousePos.y-yOffset < config.scrollAmount) { 246 | window.scrollBy(0, -config.scrollAmount); 247 | } else { 248 | var windowHeight = window.innerHeight ? window.innerHeight 249 | : document.documentElement.clientHeight ? document.documentElement.clientHeight : document.body.clientHeight; 250 | if (windowHeight-(mousePos.y-yOffset) < config.scrollAmount) { 251 | window.scrollBy(0, config.scrollAmount); 252 | } 253 | } 254 | 255 | 256 | if (y != jQuery.tableDnD.oldY) { 257 | // work out if we're going up or down... 258 | var movingDown = y > jQuery.tableDnD.oldY; 259 | // update the old value 260 | jQuery.tableDnD.oldY = y; 261 | // update the style to show we're dragging 262 | if (config.onDragClass) { 263 | dragObj.addClass(config.onDragClass); 264 | } else { 265 | dragObj.css(config.onDragStyle); 266 | } 267 | // If we're over a row then move the dragged row to there so that the user sees the 268 | // effect dynamically 269 | var currentRow = jQuery.tableDnD.findDropTargetRow(dragObj, y); 270 | if (currentRow) { 271 | // TODO worry about what happens when there are multiple TBODIES 272 | if (movingDown && jQuery.tableDnD.dragObject != currentRow) { 273 | jQuery.tableDnD.dragObject.parentNode.insertBefore(jQuery.tableDnD.dragObject, currentRow.nextSibling); 274 | } else if (! movingDown && jQuery.tableDnD.dragObject != currentRow) { 275 | jQuery.tableDnD.dragObject.parentNode.insertBefore(jQuery.tableDnD.dragObject, currentRow); 276 | } 277 | } 278 | } 279 | 280 | return false; 281 | }, 282 | 283 | /** We're only worried about the y position really, because we can only move rows up and down */ 284 | findDropTargetRow: function(draggedRow, y) { 285 | var rows = jQuery.tableDnD.currentTable.rows; 286 | for (var i=0; i rowY - rowHeight) && (y < (rowY + rowHeight))) { 296 | // that's the row we're over 297 | // If it's the same as the current row, ignore it 298 | if (row == draggedRow) {return null;} 299 | var config = jQuery.tableDnD.currentTable.tableDnDConfig; 300 | if (config.onAllowDrop) { 301 | if (config.onAllowDrop(draggedRow, row)) { 302 | return row; 303 | } else { 304 | return null; 305 | } 306 | } else { 307 | // If a row has nodrop class, then don't allow dropping (inspired by John Tarr and Famic) 308 | var nodrop = jQuery(row).hasClass("nodrop"); 309 | if (! nodrop) { 310 | return row; 311 | } else { 312 | return null; 313 | } 314 | } 315 | return row; 316 | } 317 | } 318 | return null; 319 | }, 320 | 321 | mouseup: function(e) { 322 | if (jQuery.tableDnD.currentTable && jQuery.tableDnD.dragObject) { 323 | var droppedRow = jQuery.tableDnD.dragObject; 324 | var config = jQuery.tableDnD.currentTable.tableDnDConfig; 325 | // If we have a dragObject, then we need to release it, 326 | // The row will already have been moved to the right place so we just reset stuff 327 | if (config.onDragClass) { 328 | jQuery(droppedRow).removeClass(config.onDragClass); 329 | } else { 330 | jQuery(droppedRow).css(config.onDropStyle); 331 | } 332 | jQuery.tableDnD.dragObject = null; 333 | if (config.onDrop) { 334 | // Call the onDrop method if there is one 335 | config.onDrop(jQuery.tableDnD.currentTable, droppedRow); 336 | } 337 | jQuery.tableDnD.currentTable = null; // let go of the table too 338 | } 339 | }, 340 | 341 | serialize: function() { 342 | if (jQuery.tableDnD.currentTable) { 343 | return jQuery.tableDnD.serializeTable(jQuery.tableDnD.currentTable); 344 | } else { 345 | return "Error: No Table id set, you need to set an id on your table and every row"; 346 | } 347 | }, 348 | 349 | serializeTable: function(table) { 350 | var result = ""; 351 | var tableId = table.id; 352 | var rows = table.rows; 353 | for (var i=0; i 0) result += "&"; 355 | var rowId = rows[i].id; 356 | if (rowId && rowId && table.tableDnDConfig && table.tableDnDConfig.serializeRegexp) { 357 | rowId = rowId.match(table.tableDnDConfig.serializeRegexp)[0]; 358 | } 359 | 360 | result += tableId + '[]=' + rowId; 361 | } 362 | return result; 363 | }, 364 | 365 | serializeTables: function() { 366 | var result = ""; 367 | this.each(function() { 368 | // this is now bound to each matching table 369 | result += jQuery.tableDnD.serializeTable(this); 370 | }); 371 | return result; 372 | } 373 | 374 | } 375 | 376 | jQuery.fn.extend( 377 | { 378 | tableDnD : jQuery.tableDnD.build, 379 | tableDnDUpdate : jQuery.tableDnD.updateTables, 380 | tableDnDSerialize: jQuery.tableDnD.serializeTables 381 | } 382 | ); -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # django-todo 2 | 3 | django-todo is a pluggable, multi-user, multi-group task management and 4 | 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!) 5 | 6 | **The best way to learn how django-todo works is to visit the live demo site at [django-todo.org](http://django-todo.org)!** 7 | 8 | ## Features 9 | 10 | * Drag and drop task prioritization 11 | * Email task notification 12 | * Search 13 | * Comments on tasks 14 | * Public-facing submission form for tickets 15 | * Mobile-friendly (work in progress) 16 | * Separate view for My Tasks (across lists) 17 | * Batch-import tasks via CSV 18 | * Integrated mail tracking (unify a task list with an email box) 19 | 20 | 21 | ## Requirements 22 | 23 | * Django 2.0+ 24 | * Python 3.6+ 25 | * jQuery (full version, not "slim", for drag/drop prioritization) 26 | * Bootstrap (to work with provided templates, though you can override them) 27 | * bleach (`pip install bleach`) 28 | * django-autocomplete-light (optional, required for task merging) 29 | 30 | ## Overview 31 | 32 | We assume that your organization has multiple groups of employees, each with multiple users (where actual users and groups map to Django Users and Groups). Users may belong to multiple groups, and each group can have multiple todo lists. 33 | 34 | 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. 35 | 36 | Users can view and modify all to-do lists belonging to their group(s). Only users with `is_staff` can add or delete lists. 37 | 38 | Identical list names can exist in different groups, but not in the same group. 39 | 40 | Emails are generated to the assigned-to person when new tasks are created. 41 | 42 | Comment threads can be added to tasks. Each participant in a thread receives email when new comments are added. 43 | 44 | django-todo is auth-only. You must set up a login system and at least one group before deploying. 45 | 46 | All tasks are "created by" the current user and can optionally be "assigned to" a specific user. Unassigned tickets appear as belonging to "anyone" in the UI. 47 | 48 | django-todo v2 makes use of features only available in Django 2.0. It will not work in previous versions. v2 is only tested against Python 3.x -- no guarantees if running it against older versions. 49 | 50 | ## Installation 51 | 52 | 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). 53 | 54 | If using your own site, be sure you have jQuery and Bootstrap wired up and working. 55 | 56 | django-todo views that require it will insert additional CSS/JavaScript into page heads, so your project's base templates must include: 57 | 58 | ```jinja 59 | {% block extrahead %}{% endblock extrahead %} 60 | {% block extra_js %}{% endblock extra_js %} 61 | ``` 62 | 63 | django-todo comes with its own `todo/base.html`, which extends your master `base.html`. All content lives inside of: 64 | 65 | `{% block content %}{% endblock %}` 66 | 67 | If you use some other name for your main content area, you'll need to override and alter the provided templates. 68 | 69 | All views are login-required. Therefore, you must have a working user authentication system. 70 | 71 | 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/). 72 | 73 | Make sure you've installed the Django "sites" framework and have specified the default site in settings, e.g. `SITE_ID = 1` 74 | 75 | Put django-todo/todo somewhere on your Python path, or install via pip: 76 | 77 | pip install django-todo 78 | 79 | 80 | Add to your settings: 81 | 82 | ``` 83 | INSTALLED_APPS = ( 84 | ... 85 | 'todo', 86 | ) 87 | ``` 88 | 89 | Migrate in database tables: 90 | 91 | `python manage.py migrate todo` 92 | 93 | Add to your URL conf: 94 | 95 | `path('todo/', include('todo.urls', namespace="todo")),` 96 | 97 | Add links to your site's navigation system: 98 | 99 | ``` 100 | Todo Lists 101 | My Tasks 102 | ``` 103 | 104 | 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`. 105 | 106 | Log in and access `/todo`! 107 | 108 | ### Customizing Templates 109 | 110 | 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. 111 | 112 | ### Filing Public Tickets 113 | 114 | 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). 115 | 116 | ## Settings 117 | 118 | Optional configuration params, which can be added to your project settings: 119 | 120 | ```python 121 | # Restrict access to ALL todo lists/views to `is_staff` users. 122 | # If False or unset, all users can see all views (but more granular permissions are still enforced 123 | # within views, such as requiring staff for adding and deleting lists). 124 | TODO_STAFF_ONLY = True 125 | 126 | # If you use the "public" ticket filing option, to whom should these tickets be assigned? 127 | # Must be a valid username in your system. If unset, unassigned tickets go to "Anyone." 128 | TODO_DEFAULT_ASSIGNEE = 'johndoe' 129 | 130 | # If you use the "public" ticket filing option, to which list should these tickets be saved? 131 | # Defaults to first list found, which is probably not what you want! 132 | TODO_DEFAULT_LIST_SLUG = 'tickets' 133 | 134 | # If you use the "public" ticket filing option, to which *named URL* should the user be 135 | # redirected after submitting? (since they can't see the rest of the ticket system). 136 | # Defaults to "/" 137 | TODO_PUBLIC_SUBMIT_REDIRECT = 'dashboard' 138 | 139 | # additionnal classes the comment body should hold 140 | # adding "text-monospace" makes comment monospace 141 | TODO_COMMENT_CLASSES = [] 142 | 143 | # The following two settings are relevant only if you want todo to track a support mailbox - 144 | # see Mail Tracking below. 145 | TODO_MAIL_BACKENDS 146 | TODO_MAIL_TRACKERS 147 | ``` 148 | 149 | The current django-todo version number is available from the [todo package](https://github.com/shacker/django-todo/blob/master/todo/__init__.py): 150 | 151 | python -c "import todo; print(todo.__version__)" 152 | 153 | ## Importing Tasks via CSV 154 | 155 | django-todo has the ability to batch-import ("upsert") tasks from a specifically formatted CSV spreadsheet. This ability is provided through both a management command and a web interface. 156 | 157 | **Management Command** 158 | 159 | `./manage.py import_csv -f /path/to/file.csv` 160 | 161 | **Web Importer** 162 | 163 | Link from your navigation to `{url "todo:import_csv"}`. Follow the resulting link for the CSV web upload view. 164 | 165 | 166 | ### CSV Formatting 167 | 168 | Copy `todo/data/import_example.csv` to another location on your system and edit in a spreadsheet or directly. 169 | 170 | **Do not edit the header row!** 171 | 172 | The first four columns: `'Title', 'Group', 'Task List', 'Created By'` are required -- all others are optional and should work pretty much exactly like manual task entry via the web UI. 173 | 174 | Note: Internally, Tasks are keyed to TaskLists, not to Groups (TaskLists are in Gruops). However, we request the Group in the CSV 175 | because it's possible to have multiple TaskLists with the same name in different groups; i.e. we need it for namespacing and permissions. 176 | 177 | 178 | ### Import Rules 179 | 180 | Because data entered via CSV is not going through the same view permissions enforced in the rest of django-todo, and to simplify data dependency logic, and to pre-empt disagreements between django-todo users, the importer will *not* create new users, groups, or task lists. All users, groups, and task lists referenced in your CSV must already exist, and group memberships must be correct. 181 | 182 | Any validation error (e.g. unparse-able dates, incorrect group memberships) **will result in that row being skipped.** 183 | 184 | A report of rows upserted and rows skipped (with line numbers and reasons) is provided at the end of the run. 185 | 186 | ### Upsert Logic 187 | 188 | For each valid row, we need to decide whether to create a new task or update an existing one. django-todo matches on the unique combination of the four required columns. If we find a task that matches those, we *update* the rest of the columns. In other words, if you import a CSV once, then edit the Assigned To for a task and import it again, the original task will be updated with a new assignee (and same for the other columns). 189 | 190 | Otherwise we create a new task. 191 | 192 | 193 | ## Mail Tracking 194 | 195 | What if you could turn django-todo into a shared mailbox? Django-todo includes an optional feature that allows emails 196 | sent to a dedicated mailbox to be pushed into todo as new tasks, and responses to be added as comments on those tasks. 197 | This allows support teams to work with a fully unified email + bug tracking system to avoid confusion over who's seen or 198 | responded to what. 199 | 200 | To enable mail tracking, you need to: 201 | 202 | - Define an email backend for outgoing emails 203 | - Define an email backend for incoming emails 204 | - Start a worker, which will wait for new emails 205 | 206 | In settings: 207 | 208 | ```python 209 | from todo.mail.producers import imap_producer 210 | from todo.mail.consumers import tracker_consumer 211 | from todo.mail.delivery import smtp_backend, console_backend 212 | 213 | # email notifications configuration 214 | # each task list can get its own delivery method 215 | TODO_MAIL_BACKENDS = { 216 | # mail-queue is the name of the task list, not the worker name 217 | "mail-queue": smtp_backend( 218 | host="smtp.example.com", 219 | port=465, 220 | use_ssl=True, 221 | username="test@example.com", 222 | password="foobar", 223 | # used as the From field when sending notifications. 224 | # a username might be prepended later on 225 | from_address="test@example.com", 226 | # additionnal headers 227 | headers={} 228 | ), 229 | } 230 | 231 | # incoming mail worker configuration 232 | TODO_MAIL_TRACKERS = { 233 | # configuration for worker "test_tracker" 234 | "test_tracker": { 235 | "producer": imap_producer( 236 | host="imap.example.com", 237 | username="text@example.com", 238 | password="foobar", 239 | # process_all=False, # by default, only unseen emails are processed 240 | # preserve=False, # delete emails if False 241 | # nap_duration=1, # duration of the pause between polling rounds 242 | # input_folder="INBOX", # where to read emails from 243 | ), 244 | "consumer": tracker_consumer( 245 | group="Mail Queuers", 246 | task_list_slug="mail-queue", 247 | priority=1, 248 | task_title_format="[TEST_MAIL] {subject}", 249 | ) 250 | } 251 | } 252 | ``` 253 | 254 | A mail worker can be started with: 255 | 256 | ```sh 257 | ./manage.py mail_worker test_tracker 258 | ``` 259 | 260 | Some views and URLs were renamed in 2.0 for logical consistency. If this affects you, see source code and the demo GTD site for reference to the new URL names. 261 | 262 | If you want to log mail events, make sure to properly configure django logging: 263 | 264 | ```python 265 | LOGGING = { 266 | 'version': 1, 267 | 'disable_existing_loggers': False, 268 | 'handlers': { 269 | 'console': { 270 | 'class': 'logging.StreamHandler', 271 | }, 272 | }, 273 | 'loggers': { 274 | '': { 275 | 'handlers': ['console'], 276 | 'level': 'DEBUG', 277 | 'propagate': True, 278 | }, 279 | }, 280 | } 281 | ``` 282 | 283 | 284 | ## Running Tests 285 | 286 | django-todo uses pytest exclusively for testing. The best way to run the suite is to clone django-todo into its own directory, install pytest, then: 287 | 288 | pip install pytest pytest-django 289 | pip install --editable . 290 | pytest -x -v 291 | 292 | The previous `tox` system was removed with the v2 release, since we no longer aim to support older Python or Django versions. 293 | 294 | 295 | ## Upgrade Notes 296 | 297 | django-todo 2.0 was rebuilt almost from the ground up, and included some radical changes, including model name changes. As a result, it is *not compatible* with data from django-todo 1.x. If you would like to upgrade an existing installation, try this: 298 | 299 | * Use `./manage.py dumpdata todo --indent 4 > todo.json` to export your old todo data 300 | * Edit the dump file, replacing the old model names `Item` and `List` with the new model names (`Task` and `TaskList`) 301 | * Delete your existing todo data 302 | * Uninstall the old todo app and reinstall 303 | * Migrate, then use `./manage.py loaddata todo.json` to import the edited data 304 | 305 | ### Why not provide migrations? 306 | 307 | That was the plan, but unfortunately, `makemigrations` created new tables and dropped the old ones, making this a destructive update. Renaming models is unfortunately not something `makemigrations` can do, and I really didn't want to keep the badly named original models. Sorry! 308 | 309 | ### Datepicker 310 | 311 | django-todo no longer references a jQuery datepicker, but defaults to native html5 browser datepicker (not supported by Safari, unforunately). Feel free to implement one of your choosing. 312 | 313 | ## Version History 314 | 315 | **2.3.0** Implement mail tracking system. Added ability to batch-import tasks via CSV. Fixed task re-ordering if task deleted behind the scenes. 316 | 317 | **2.2.2** Update dependencies 318 | 319 | **2.2.1** Convert task delete and toggle_done views to POST only 320 | 321 | **2.2.0** Re-instate enforcement of TODO_STAFF_ONLY setting 322 | 323 | **2.1.1** Correct Python version requirement in documentation to Python 3.6 324 | 325 | **2.1.1** Split up views into separate modules. 326 | 327 | **2.1.0** December 2018: No longer allowing Javascript in task or comment bodies. Misc bug fixes. 328 | 329 | **2.0.3** April 2018: Bump production status in setup.py 330 | 331 | **2.0.2** April 2018: Improve notification email subjects and bodies 332 | 333 | **2.0.1** April 2018: Refactored "toggle done" and "delete" actions from list view. 334 | 335 | **2.0** April 2018: Major project refactor, with almost completely rewritten views, templates, and todo's first real test suite. 336 | 337 | **1.6.2** Added support for unicode characters in list name/slugs. 338 | 339 | **1.6.1** Minor bug fixes. 340 | 341 | **1.6** Allow unassigned ("Anyone") tasks. Clean-up / modernize templates and views. Testing infrastructure in place. 342 | 343 | **1.5** flake8 support, Item note no longer a required field, fix warnings for Django 1.8, Python 2/3-compatible unicode strings, simple search for tasks, get_absolute_url() for items. 344 | 345 | **1.4** - Removed styling from default templates. Added excludes fields from Form definitions to prevent warnings. Removed deprecated 'cycle' tags from templates. Added settings for various elements for public ticket submissions. 346 | 347 | **1.3** - Removed stray direct_to_template reference. Quoted all named URL references for Django 1.5 compatibility. 348 | 349 | **1.2** - Added CSRF protection to all sample templates. Added integrated search function. Now showing the ratio of completed/total items for each 350 | list. Better separation of media and templates. Cleaned up Item editing form (removed extraneous fields). Re-assigning tasks now properly limits 351 | the list of assignees. Moved project to github. 352 | 353 | **1.1** - Completion date was set properly when checking items off a list, but not when saving from an Item detail page. Added a save method on Item to 354 | fix. Fixed documentation bug re: context_processors. Newly added comments are now emailed to everyone who has participated in a thread on a task. 355 | 356 | **1.0.1** - When viewing a single task that you want to close, it's useful to be able to comment on and close a task at the same time. We were using 357 | django-comments so these were different models in different views. Solution was to stop using django-comments and roll our own, then rewire the 358 | view. Apologies if you were using a previous version - you may need to port over your comments to the new system. 359 | 360 | **1.0.0** - Major upgrade to release version. Drag and drop task prioritization. E-mail notifications (now works more like a ticket system). More 361 | attractive date picker. Bug fixes. 362 | 363 | **0.9.5** - Fixed jquery bug when editing existing events - datepicker now shows correct date. Removed that damned Django pony from base template. 364 | 365 | **0.9.4** - Replaced str with unicode in models. Fixed links back to lists in "My Tasks" view. 366 | 367 | **0.9.3** - Missing link to the individual task editing view 368 | 369 | **0.9.2** - Now fails gracefully when trying to add a 2nd list with the same name to the same group. - Due dates for tasks are now truly optional. - 370 | Corrected datetime editing conflict when editing tasks - Max length of a task name has been raised from 60 to 140 chars. If upgrading, please 371 | modify your database accordingly (field todo_item.name = maxlength 140). - Security: Users supplied with direct task URLs can no longer view/edit 372 | tasks outside their group scope Same for list views - authorized views only. - Correct item and group counts on homepage (note - admin users see 373 | ALL groups, not just the groups they "belong" to) 374 | 375 | **0.9.1** - Removed context_processors.py - leftover turdlet 376 | 377 | **0.9** - First release 378 | 379 | ## Todo 2.0 Upgrade Notes 380 | 381 | django-todo 2.0 was rebuilt almost from the ground up, and included some radical changes, including model name changes. As a result, it is *not compatible* with data from django-todo 1.x. If you would like to upgrade an existing installation, try this: 382 | 383 | * Use `./manage.py dumpdata todo --indent 4 > todo.json` to export your old todo data 384 | * Edit the dump file, replacing the old model names `Item` and `List` with the new model names (`Task` and `TaskList`) 385 | * Delete your existing todo data 386 | * Uninstall the old todo app and reinstall 387 | * Migrate, then use `./manage.py loaddata todo.json` to import the edited data 388 | 389 | ### Why not provide migrations? 390 | 391 | That was the plan, but unfortunately, `makemigrations` created new tables and dropped the old ones, making this a destructive update. Renaming models is unfortunately not something `makemigrations` can do, and I really didn't want to keep the badly named original models. Sorry! 392 | 393 | ### Datepicker 394 | 395 | django-todo no longer references a jQuery datepicker, but defaults to native html5 browser datepicker (not supported by Safari, unforunately). Feel free to implement one of your choosing. 396 | 397 | ### URLs 398 | 399 | Some views and URLs were renamed for logical consistency. If this affects you, see source code and the demo GTD site for reference to the new URL names. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # django-todo 2 | 3 | django-todo is a pluggable, multi-user, multi-group task management and 4 | 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!) 5 | 6 | **The best way to learn how django-todo works is to visit the live demo site at [django-todo.org](http://django-todo.org)!** 7 | 8 | ## Features 9 | 10 | * Drag and drop task prioritization 11 | * Email task notification 12 | * Search 13 | * Comments on tasks 14 | * Public-facing submission form for tickets 15 | * Mobile-friendly (work in progress) 16 | * Separate view for My Tasks (across lists) 17 | * Batch-import tasks via CSV 18 | * Batch-export tasks in CSV Format 19 | * Multiple file attachments per task (see settings) 20 | * Integrated mail tracking (unify a task list with an email box) 21 | 22 | 23 | ## Requirements 24 | 25 | * Django 2.0+ 26 | * Python 3.6+ 27 | * jQuery (full version, not "slim", for drag/drop prioritization) 28 | * Bootstrap (to work with provided templates, though you can override them) 29 | * bleach (`pip install bleach`) 30 | * django-autocomplete-light (optional, required for task merging) 31 | 32 | ## Overview 33 | 34 | We assume that your organization has multiple groups of employees, each with multiple users (where actual users and groups map to Django Users and Groups). Users may belong to multiple groups, and each group can have multiple todo lists. 35 | 36 | 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. 37 | 38 | Users can view and modify all to-do lists belonging to their group(s). Only users with `is_staff` can add or delete lists. 39 | 40 | Identical list names can exist in different groups, but not in the same group. 41 | 42 | Emails are generated to the assigned-to person when new tasks are created. 43 | 44 | File attachments of a few types are allowed on tasks by default. See settings to disable or to limit filetypes. If you are concerned about file sizes, limit them in your web server configuration (not currently handled separately by django-todo). 45 | 46 | Comment threads can be added to tasks. Each participant in a thread receives email when new comments are added. 47 | 48 | django-todo is auth-only. You must set up a login system and at least one group before deploying. 49 | 50 | All tasks are "created by" the current user and can optionally be "assigned to" a specific user. Unassigned tickets appear as belonging to "anyone" in the UI. 51 | 52 | django-todo v2 makes use of features only available in Django 2.0. It will not work in previous versions. v2 is only tested against Python 3.x -- no guarantees if running it against older versions. 53 | 54 | ## Installation 55 | 56 | 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). 57 | 58 | If using your own site, be sure you have jQuery and Bootstrap wired up and working. 59 | 60 | django-todo views that require it will insert additional CSS/JavaScript into page heads, so your project's base templates must include: 61 | 62 | ```jinja 63 | {% block extrahead %}{% endblock extrahead %} 64 | {% block extra_js %}{% endblock extra_js %} 65 | ``` 66 | 67 | django-todo comes with its own `todo/base.html`, which extends your master `base.html`. All content lives inside of: 68 | 69 | `{% block content %}{% endblock %}` 70 | 71 | If you use some other name for your main content area, you'll need to override and alter the provided templates. 72 | 73 | All views are login-required. Therefore, you must have a working user authentication system. 74 | 75 | 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/). 76 | 77 | Make sure you've installed the Django "sites" framework and have specified the default site in settings, e.g. `SITE_ID = 1` 78 | 79 | Put django-todo/todo somewhere on your Python path, or install via pip: 80 | 81 | pip install django-todo 82 | 83 | 84 | Add to your settings: 85 | 86 | ``` 87 | INSTALLED_APPS = ( 88 | ... 89 | 'todo', 90 | ) 91 | ``` 92 | 93 | Migrate in database tables: 94 | 95 | `python manage.py migrate todo` 96 | 97 | Add to your URL conf: 98 | 99 | `path('todo/', include('todo.urls', namespace="todo")),` 100 | 101 | Add links to your site's navigation system: 102 | 103 | ``` 104 | Todo Lists 105 | My Tasks 106 | ``` 107 | 108 | django-todo makes use of the Django `messages` system. Make sure you have something like [this](https://docs.djangoproject.com/en/2.1/ref/contrib/messages/#displaying-messages) (link) in your `base.html`. 109 | 110 | Log in and access `/todo`! 111 | 112 | ### Customizing Templates 113 | 114 | 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. 115 | 116 | ### Filing Public Tickets 117 | 118 | 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). 119 | 120 | ## Settings 121 | 122 | Optional configuration params, which can be added to your project settings: 123 | 124 | ```python 125 | # Restrict access to ALL todo lists/views to `is_staff` users. 126 | # If False or unset, all users can see all views (but more granular permissions are still enforced 127 | # within views, such as requiring staff for adding and deleting lists). 128 | TODO_STAFF_ONLY = True 129 | 130 | # If you use the "public" ticket filing option, to whom should these tickets be assigned? 131 | # Must be a valid username in your system. If unset, unassigned tickets go to "Anyone." 132 | TODO_DEFAULT_ASSIGNEE = 'johndoe' 133 | 134 | # If you use the "public" ticket filing option, to which list should these tickets be saved? 135 | # Defaults to first list found, which is probably not what you want! 136 | TODO_DEFAULT_LIST_SLUG = 'tickets' 137 | 138 | # If you use the "public" ticket filing option, to which *named URL* should the user be 139 | # redirected after submitting? (since they can't see the rest of the ticket system). 140 | # Defaults to "/" 141 | TODO_PUBLIC_SUBMIT_REDIRECT = 'dashboard' 142 | 143 | # Enable or disable file attachments on Tasks 144 | # Optionally limit list of allowed filetypes 145 | TODO_ALLOW_FILE_ATTACHMENTS = True 146 | TODO_ALLOWED_FILE_ATTACHMENTS = [".jpg", ".gif", ".csv", ".pdf", ".zip"] 147 | TODO_MAXIMUM_ATTACHMENT_SIZE = 5000000 # In bytes 148 | 149 | # Additional classes the comment body should hold. 150 | # Adding "text-monospace" makes comment monospace 151 | TODO_COMMENT_CLASSES = [] 152 | 153 | # The following two settings are relevant only if you want todo to track a support mailbox - 154 | # see Mail Tracking below. 155 | TODO_MAIL_BACKENDS 156 | TODO_MAIL_TRACKERS 157 | ``` 158 | 159 | The current django-todo version number is available from the [todo package](https://github.com/shacker/django-todo/blob/master/todo/__init__.py): 160 | 161 | python -c "import todo; print(todo.__version__)" 162 | 163 | ## Importing Tasks via CSV 164 | 165 | django-todo has the ability to batch-import ("upsert") tasks from a specifically formatted CSV spreadsheet. This ability is provided through both a management command and a web interface. 166 | 167 | **Management Command** 168 | 169 | `./manage.py import_csv -f /path/to/file.csv` 170 | 171 | **Web Importer** 172 | 173 | Link from your navigation to `{url "todo:import_csv"}`. Follow the resulting link for the CSV web upload view. 174 | 175 | 176 | ### CSV Formatting 177 | 178 | Copy `todo/data/import_example.csv` to another location on your system and edit in a spreadsheet or directly. 179 | 180 | **Do not edit the header row!** 181 | 182 | The first four columns: `'Title', 'Group', 'Task List', 'Created By'` are required -- all others are optional and should work pretty much exactly like manual task entry via the web UI. 183 | 184 | Note: Internally, Tasks are keyed to TaskLists, not to Groups (TaskLists are in Gruops). However, we request the Group in the CSV 185 | because it's possible to have multiple TaskLists with the same name in different groups; i.e. we need it for namespacing and permissions. 186 | 187 | 188 | ### Import Rules 189 | 190 | Because data entered via CSV is not going through the same view permissions enforced in the rest of django-todo, and to simplify data dependency logic, and to pre-empt disagreements between django-todo users, the importer will *not* create new users, groups, or task lists. All users, groups, and task lists referenced in your CSV must already exist, and group memberships must be correct. 191 | 192 | Any validation error (e.g. unparse-able dates, incorrect group memberships) **will result in that row being skipped.** 193 | 194 | A report of rows upserted and rows skipped (with line numbers and reasons) is provided at the end of the run. 195 | 196 | ### Upsert Logic 197 | 198 | For each valid row, we need to decide whether to create a new task or update an existing one. django-todo matches on the unique combination of the four required columns. If we find a task that matches those, we *update* the rest of the columns. In other words, if you import a CSV once, then edit the Assigned To for a task and import it again, the original task will be updated with a new assignee (and same for the other columns). 199 | 200 | Otherwise we create a new task. 201 | 202 | 203 | ## Mail Tracking 204 | 205 | What if you could turn django-todo into a shared mailbox? Django-todo includes an optional feature that allows emails 206 | sent to a dedicated mailbox to be pushed into todo as new tasks, and responses to be added as comments on those tasks. 207 | This allows support teams to work with a fully unified email + bug tracking system to avoid confusion over who's seen or 208 | responded to what. 209 | 210 | To enable mail tracking, you need to: 211 | 212 | - Define an email backend for outgoing emails 213 | - Define an email backend for incoming emails 214 | - Start a worker, which will wait for new emails 215 | 216 | In settings: 217 | 218 | ```python 219 | from todo.mail.producers import imap_producer 220 | from todo.mail.consumers import tracker_consumer 221 | from todo.mail.delivery import smtp_backend, console_backend 222 | 223 | # email notifications configuration 224 | # each task list can get its own delivery method 225 | TODO_MAIL_BACKENDS = { 226 | # mail-queue is the name of the task list, not the worker name 227 | "mail-queue": smtp_backend( 228 | host="smtp.example.com", 229 | port=465, 230 | use_ssl=True, 231 | username="test@example.com", 232 | password="foobar", 233 | # used as the From field when sending notifications. 234 | # a username might be prepended later on 235 | from_address="test@example.com", 236 | # additionnal headers 237 | headers={} 238 | ), 239 | } 240 | 241 | # incoming mail worker configuration 242 | TODO_MAIL_TRACKERS = { 243 | # configuration for worker "test_tracker" 244 | "test_tracker": { 245 | "producer": imap_producer( 246 | host="imap.example.com", 247 | username="text@example.com", 248 | password="foobar", 249 | # process_all=False, # by default, only unseen emails are processed 250 | # preserve=False, # delete emails if False 251 | # nap_duration=1, # duration of the pause between polling rounds 252 | # input_folder="INBOX", # where to read emails from 253 | ), 254 | "consumer": tracker_consumer( 255 | group="Mail Queuers", 256 | task_list_slug="mail-queue", 257 | priority=1, 258 | task_title_format="[TEST_MAIL] {subject}", 259 | ) 260 | } 261 | } 262 | ``` 263 | 264 | Optionally, the email addresses of incoming emails can be mapped back to django users. If a user emails the test_tracker, and also is a registered User in your application, the user will show up as having created the task or comment. By default, only the email address will show up. 265 | 266 | This isn't enabled by default, as some domains are misconfigured and do not prevent impersonation. If this option is enabled and your setup doesn't properly authenticate emails, malicious incoming emails might mistakenly be attributed to users. 267 | 268 | Settings: 269 | ```python 270 | TODO_MAIL_USER_MAPPER = None # Set to True if you would like to match users. If you do not have authentication setup, do not set this to True. 271 | ``` 272 | 273 | A mail worker can be started with: 274 | 275 | ```sh 276 | ./manage.py mail_worker test_tracker 277 | ``` 278 | 279 | Some views and URLs were renamed in 2.0 for logical consistency. If this affects you, see source code and the demo GTD site for reference to the new URL names. 280 | 281 | If you want to log mail events, make sure to properly configure django logging: 282 | 283 | ```python 284 | LOGGING = { 285 | 'version': 1, 286 | 'disable_existing_loggers': False, 287 | 'handlers': { 288 | 'console': { 289 | 'class': 'logging.StreamHandler', 290 | }, 291 | }, 292 | 'loggers': { 293 | '': { 294 | 'handlers': ['console'], 295 | 'level': 'DEBUG', 296 | 'propagate': True, 297 | }, 298 | }, 299 | } 300 | ``` 301 | 302 | 303 | ## Running Tests 304 | 305 | django-todo uses pytest exclusively for testing. The best way to run the suite is to clone django-todo into its own directory, install pytest, then: 306 | 307 | pip install pytest pytest-django 308 | pip install --editable . 309 | pytest -x -v 310 | 311 | ## Version History 312 | 313 | **2.5.0** Change setup to pyprojec.toml 314 | 315 | **2.4.11** Add SECURITY.md 316 | 317 | **2.4.10** It is now possible to use unicode characters (such as Chinese) as the only chars in a list title. 318 | 319 | **2.4.9** Fixed: Editing a task should not change its completed/incomplete status 320 | 321 | **2.4.8** Fix bug when setting default values for unspecified settings 322 | 323 | **2.4.7** Support custom user model in external_add 324 | 325 | **2.4.6** Use `defaults` hash for default settings, update perms and tests 326 | 327 | **2.4.5** Re-enable "notify" feature during task edit 328 | 329 | **2.4.4** Fix issues with setup.py / installation 330 | 331 | **2.4.0** Implement optional file attachments on tasks 332 | 333 | **2.3.2** Update setup.py metadata 334 | 335 | **2.3.1** Improve error handling for badly formatted or non-existent CSV uploads. 336 | 337 | **2.3.0** Implement mail tracking system. Added ability to batch-import tasks via CSV. Fixed task re-ordering if task deleted behind the scenes. 338 | 339 | **2.2.2** Update dependencies 340 | 341 | **2.2.1** Convert task delete and toggle_done views to POST only 342 | 343 | **2.2.0** Re-instate enforcement of TODO_STAFF_ONLY setting 344 | 345 | **2.1.1** Correct Python version requirement in documentation to Python 3.6 346 | 347 | **2.1.1** Split up views into separate modules. 348 | 349 | **2.1.0** December 2018: No longer allowing Javascript in task or comment bodies. Misc bug fixes. 350 | 351 | **2.0.3** April 2018: Bump production status in setup.py 352 | 353 | **2.0.2** April 2018: Improve notification email subjects and bodies 354 | 355 | **2.0.1** April 2018: Refactored "toggle done" and "delete" actions from list view. 356 | 357 | **2.0** April 2018: Major project refactor, with almost completely rewritten views, templates, and todo's first real test suite. 358 | 359 | **1.6.2** Added support for unicode characters in list name/slugs. 360 | 361 | **1.6.1** Minor bug fixes. 362 | 363 | **1.6** Allow unassigned ("Anyone") tasks. Clean-up / modernize templates and views. Testing infrastructure in place. 364 | 365 | **1.5** flake8 support, Item note no longer a required field, fix warnings for Django 1.8, Python 2/3-compatible unicode strings, simple search for tasks, get_absolute_url() for items. 366 | 367 | **1.4** - Removed styling from default templates. Added excludes fields from Form definitions to prevent warnings. Removed deprecated 'cycle' tags from templates. Added settings for various elements for public ticket submissions. 368 | 369 | **1.3** - Removed stray direct_to_template reference. Quoted all named URL references for Django 1.5 compatibility. 370 | 371 | **1.2** - Added CSRF protection to all sample templates. Added integrated search function. Now showing the ratio of completed/total items for each 372 | list. Better separation of media and templates. Cleaned up Item editing form (removed extraneous fields). Re-assigning tasks now properly limits 373 | the list of assignees. Moved project to github. 374 | 375 | **1.1** - Completion date was set properly when checking items off a list, but not when saving from an Item detail page. Added a save method on Item to 376 | fix. Fixed documentation bug re: context_processors. Newly added comments are now emailed to everyone who has participated in a thread on a task. 377 | 378 | **1.0.1** - When viewing a single task that you want to close, it's useful to be able to comment on and close a task at the same time. We were using 379 | django-comments so these were different models in different views. Solution was to stop using django-comments and roll our own, then rewire the 380 | view. Apologies if you were using a previous version - you may need to port over your comments to the new system. 381 | 382 | **1.0.0** - Major upgrade to release version. Drag and drop task prioritization. E-mail notifications (now works more like a ticket system). More 383 | attractive date picker. Bug fixes. 384 | 385 | **0.9.5** - Fixed jquery bug when editing existing events - datepicker now shows correct date. Removed that damned Django pony from base template. 386 | 387 | **0.9.4** - Replaced str with unicode in models. Fixed links back to lists in "My Tasks" view. 388 | 389 | **0.9.3** - Missing link to the individual task editing view 390 | 391 | **0.9.2** - Now fails gracefully when trying to add a 2nd list with the same name to the same group. - Due dates for tasks are now truly optional. - 392 | Corrected datetime editing conflict when editing tasks - Max length of a task name has been raised from 60 to 140 chars. If upgrading, please 393 | modify your database accordingly (field todo_item.name = maxlength 140). - Security: Users supplied with direct task URLs can no longer view/edit 394 | tasks outside their group scope Same for list views - authorized views only. - Correct item and group counts on homepage (note - admin users see 395 | ALL groups, not just the groups they "belong" to) 396 | 397 | **0.9.1** - Removed context_processors.py - leftover turdlet 398 | 399 | **0.9** - First release 400 | 401 | ## Todo 2.0 Upgrade Notes 402 | 403 | django-todo 2.0 was rebuilt almost from the ground up, and included some radical changes, including model name changes. As a result, it is *not compatible* with data from django-todo 1.x. If you would like to upgrade an existing installation, try this: 404 | 405 | * Use `./manage.py dumpdata todo --indent 4 > todo.json` to export your old todo data 406 | * Edit the dump file, replacing the old model names `Item` and `List` with the new model names (`Task` and `TaskList`) 407 | * Delete your existing todo data 408 | * Uninstall the old todo app and reinstall 409 | * Migrate, then use `./manage.py loaddata todo.json` to import the edited data 410 | 411 | ### Why not provide migrations? 412 | 413 | That was the plan, but unfortunately, `makemigrations` created new tables and dropped the old ones, making this a destructive update. Renaming models is unfortunately not something `makemigrations` can do, and I really didn't want to keep the badly named original models. Sorry! 414 | 415 | ### Datepicker 416 | 417 | django-todo no longer references a jQuery datepicker, but defaults to native html5 browser datepicker. Feel free to implement one of your choosing. 418 | 419 | ### URLs 420 | 421 | Some views and URLs were renamed for logical consistency. If this affects you, see source code and the demo GTD site for reference to the new URL names. 422 | --------------------------------------------------------------------------------