Merge branch 'master' into csv

This commit is contained in:
Scot Hacker 2019-03-16 00:52:21 -07:00
commit 9ba7e78bf3
28 changed files with 1210 additions and 315 deletions

View file

@ -9,9 +9,9 @@ addons:
postgresql: "9.6" postgresql: "9.6"
install: install:
- "pip3 install -e . --upgrade" - "pip3 install pipenv"
- "pip3 install git+https://github.com/pypa/pipenv.git"
- "pipenv install --dev" - "pipenv install --dev"
- "pip3 install -e . --upgrade"
language: python language: python
python: python:

View file

@ -7,16 +7,17 @@ name = "pypi"
django = "*" django = "*"
django-extensions = "*" django-extensions = "*"
"psycopg2-binary" = "*" "psycopg2-binary" = "*"
pytest = "*"
pytest-django = "*"
"flake8" = "*" "flake8" = "*"
factory-boy = "*" factory-boy = "*"
titlecase = "*" titlecase = "*"
bleach = "*" bleach = "*"
django-autocomplete-light = "*"
html2text = "*"
[dev-packages] [dev-packages]
pylint = "*"
mypy = "*" mypy = "*"
pytest = "*"
pytest-django = "*"
[requires] [requires]
python_version = "3.6" python_version = "3.6"

190
Pipfile.lock generated
View file

@ -1,7 +1,7 @@
{ {
"_meta": { "_meta": {
"hash": { "hash": {
"sha256": "77ef40c5c921d99d1a0826dca9dd8328afcf0d75031cbf01d65b581c6c4fbe57" "sha256": "c6fb601fc8a197ca280960d831a5386313c93ebe19d932afa01034d5520f2f94"
}, },
"pipfile-spec": 6, "pipfile-spec": 6,
"requires": { "requires": {
@ -16,20 +16,6 @@
] ]
}, },
"default": { "default": {
"atomicwrites": {
"hashes": [
"sha256:03472c30eb2c5d1ba9227e4c2ca66ab8287fbfbbda3888aa93dc2e28fc6811b4",
"sha256:75a9445bac02d8d058d5e1fe689654ba5a6556a1dfd8ce6ec55a0ed79866cfa6"
],
"version": "==1.3.0"
},
"attrs": {
"hashes": [
"sha256:10cbf6e27dbce8c30807caf056c8eb50917e0eaafe86347671b57254006c3e69",
"sha256:ca4be454458f9dec299268d472aaa5a11f67a4ff70093396e1ceae9c76cf4bbb"
],
"version": "==18.2.0"
},
"bleach": { "bleach": {
"hashes": [ "hashes": [
"sha256:213336e49e102af26d9cde77dd2d0397afabc5a6bf2fed985dc35b5d1e285a16", "sha256:213336e49e102af26d9cde77dd2d0397afabc5a6bf2fed985dc35b5d1e285a16",
@ -40,19 +26,26 @@
}, },
"django": { "django": {
"hashes": [ "hashes": [
"sha256:a32c22af23634e1d11425574dce756098e015a165be02e4690179889b207c7a8", "sha256:275bec66fd2588dd517ada59b8bfb23d4a9abc5a362349139ddda3c7ff6f5ade",
"sha256:d6393918da830530a9516bbbcbf7f1214c3d733738779f06b0f649f49cc698c3" "sha256:939652e9d34d7d53d74d5d8ef82a19e5f8bb2de75618f7e5360691b6e9667963"
], ],
"index": "pypi", "index": "pypi",
"version": "==2.1.5" "version": "==2.1.7"
},
"django-autocomplete-light": {
"hashes": [
"sha256:996cc62519a6e2e9cd1c26e57ddc5f14541209a93e62e83d7b3df3ba65c1f458"
],
"index": "pypi",
"version": "==3.3.2"
}, },
"django-extensions": { "django-extensions": {
"hashes": [ "hashes": [
"sha256:6fcedb2ea660c9dbf9ac59441721ffdd4ab5b753fbd6159c3e28f391a65bab46", "sha256:109004f80b6f45ad1f56addaa59debca91d94aa0dc1cb19678b9364b4fe9b6f4",
"sha256:a607459e5fa8c579a672131b63366fa52fab80adb2a862d362f5fb48cd2d2cac" "sha256:307766e5e6c1caffe76c5d99239d8115d14ae3f7cab2cd991fcffd763dad904b"
], ],
"index": "pypi", "index": "pypi",
"version": "==2.1.5" "version": "==2.1.6"
}, },
"entrypoints": { "entrypoints": {
"hashes": [ "hashes": [
@ -78,11 +71,19 @@
}, },
"flake8": { "flake8": {
"hashes": [ "hashes": [
"sha256:c3ba1e130c813191db95c431a18cb4d20a468e98af7a77e2181b68574481ad36", "sha256:859996073f341f2670741b51ec1e67a01da142831aa1fdc6242dbf88dffbe661",
"sha256:fd9ddf503110bf3d8b1d270e8c673aab29ccb3dd6abf29bae1f54e5116ab4a91" "sha256:a796a115208f5c03b18f332f7c11729812c8c3ded6c46319c59b53efd3819da8"
], ],
"index": "pypi", "index": "pypi",
"version": "==3.7.5" "version": "==3.7.7"
},
"html2text": {
"hashes": [
"sha256:490db40fe5b2cd79c461cf56be4d39eb8ca68191ae41ba3ba79f6cb05b7dd662",
"sha256:627514fb30e7566b37be6900df26c2c78a030cc9e6211bda604d8181233bcdd4"
],
"index": "pypi",
"version": "==2018.1.9"
}, },
"mccabe": { "mccabe": {
"hashes": [ "hashes": [
@ -91,21 +92,6 @@
], ],
"version": "==0.6.1" "version": "==0.6.1"
}, },
"more-itertools": {
"hashes": [
"sha256:38a936c0a6d98a38bcc2d03fdaaedaba9f412879461dd2ceff8d37564d6522e4",
"sha256:c0a5785b1109a6bd7fac76d6837fd1feca158e54e521ccd2ae8bfe393cc9d4fc",
"sha256:fe7a7cae1ccb57d33952113ff4fa1bc5f879963600ed74918f1236e212ee50b9"
],
"version": "==5.0.0"
},
"pluggy": {
"hashes": [
"sha256:8ddc32f03971bfdf900a81961a48ccf2fb677cf7715108f85295c67405798616",
"sha256:980710797ff6a041e9a73a5787804f848996ecaa6f8a1b1e08224a5894f2074a"
],
"version": "==0.8.1"
},
"psycopg2-binary": { "psycopg2-binary": {
"hashes": [ "hashes": [
"sha256:19a2d1f3567b30f6c2bb3baea23f74f69d51f0c06c2e2082d0d9c28b0733a4c2", "sha256:19a2d1f3567b30f6c2bb3baea23f74f69d51f0c06c2e2082d0d9c28b0733a4c2",
@ -142,13 +128,6 @@
"index": "pypi", "index": "pypi",
"version": "==2.7.7" "version": "==2.7.7"
}, },
"py": {
"hashes": [
"sha256:bf92637198836372b520efcba9e020c330123be8ce527e535d185ed4b6f45694",
"sha256:e76826342cefe3c3d5f7e8ee4316b80d1dd8a300781612ddbc765c17ba25a6c6"
],
"version": "==1.7.0"
},
"pycodestyle": { "pycodestyle": {
"hashes": [ "hashes": [
"sha256:95a2219d12372f05704562a14ec30bc76b05a5b297b21a5dfe3f6fac3491ae56", "sha256:95a2219d12372f05704562a14ec30bc76b05a5b297b21a5dfe3f6fac3491ae56",
@ -158,26 +137,10 @@
}, },
"pyflakes": { "pyflakes": {
"hashes": [ "hashes": [
"sha256:5e8c00e30c464c99e0b501dc160b13a14af7f27d4dffb529c556e30a159e231d", "sha256:17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0",
"sha256:f277f9ca3e55de669fba45b7393a1449009cff5a37d1af10ebb76c52765269cd" "sha256:d976835886f8c5b31d47970ed689944a0262b5f3afa00a5a7b4dc81e5449f8a2"
], ],
"version": "==2.1.0" "version": "==2.1.1"
},
"pytest": {
"hashes": [
"sha256:65aeaa77ae87c7fc95de56285282546cfa9c886dc8e5dc78313db1c25e21bc07",
"sha256:6ac6d467d9f053e95aaacd79f831dbecfe730f419c6c7022cb316b365cd9199d"
],
"index": "pypi",
"version": "==4.2.0"
},
"pytest-django": {
"hashes": [
"sha256:3d489db7c9bd18d7c154347b1bdfb82cc6b1ec8539543508b199c77e5eb2caec",
"sha256:87c31e53ad09ca4f061b82a9d71ad1e3e399c7a5ec9d28f7c3c38a9a9afbd027"
],
"index": "pypi",
"version": "==3.4.7"
}, },
"python-dateutil": { "python-dateutil": {
"hashes": [ "hashes": [
@ -224,61 +187,27 @@
} }
}, },
"develop": { "develop": {
"astroid": { "atomicwrites": {
"hashes": [ "hashes": [
"sha256:35b032003d6a863f5dcd7ec11abd5cd5893428beaa31ab164982403bcb311f22", "sha256:03472c30eb2c5d1ba9227e4c2ca66ab8287fbfbbda3888aa93dc2e28fc6811b4",
"sha256:6a5d668d7dc69110de01cdf7aeec69a679ef486862a0850cc0fd5571505b6b7e" "sha256:75a9445bac02d8d058d5e1fe689654ba5a6556a1dfd8ce6ec55a0ed79866cfa6"
], ],
"version": "==2.1.0" "version": "==1.3.0"
}, },
"isort": { "attrs": {
"hashes": [ "hashes": [
"sha256:1153601da39a25b14ddc54955dbbacbb6b2d19135386699e2ad58517953b34af", "sha256:69c0dbf2ed392de1cb5ec704444b08a5ef81680a61cb899dc08127123af36a79",
"sha256:b9c40e9750f3d77e6e4d441d8b0266cf555e7cdabdcff33c4fd06366ca761ef8", "sha256:f0b870f674851ecbfbbbd364d6b5cbdff9dcedbc7f3f5e18a6891057f21fe399"
"sha256:ec9ef8f4a9bc6f71eec99e1806bfa2de401650d996c59330782b89a5555c1497"
], ],
"version": "==4.3.4" "version": "==19.1.0"
}, },
"lazy-object-proxy": { "more-itertools": {
"hashes": [ "hashes": [
"sha256:0ce34342b419bd8f018e6666bfef729aec3edf62345a53b537a4dcc115746a33", "sha256:0125e8f60e9e031347105eb1682cef932f5e97d7b9a1a28d9bf00c22a5daef40",
"sha256:1b668120716eb7ee21d8a38815e5eb3bb8211117d9a90b0f8e21722c0758cc39", "sha256:590044e3942351a1bdb1de960b739ff4ce277960f2425ad4509446dbace8d9d1"
"sha256:209615b0fe4624d79e50220ce3310ca1a9445fd8e6d3572a896e7f9146bbf019",
"sha256:27bf62cb2b1a2068d443ff7097ee33393f8483b570b475db8ebf7e1cba64f088",
"sha256:27ea6fd1c02dcc78172a82fc37fcc0992a94e4cecf53cb6d73f11749825bd98b",
"sha256:2c1b21b44ac9beb0fc848d3993924147ba45c4ebc24be19825e57aabbe74a99e",
"sha256:2df72ab12046a3496a92476020a1a0abf78b2a7db9ff4dc2036b8dd980203ae6",
"sha256:320ffd3de9699d3892048baee45ebfbbf9388a7d65d832d7e580243ade426d2b",
"sha256:50e3b9a464d5d08cc5227413db0d1c4707b6172e4d4d915c1c70e4de0bbff1f5",
"sha256:5276db7ff62bb7b52f77f1f51ed58850e315154249aceb42e7f4c611f0f847ff",
"sha256:61a6cf00dcb1a7f0c773ed4acc509cb636af2d6337a08f362413c76b2b47a8dd",
"sha256:6ae6c4cb59f199d8827c5a07546b2ab7e85d262acaccaacd49b62f53f7c456f7",
"sha256:7661d401d60d8bf15bb5da39e4dd72f5d764c5aff5a86ef52a042506e3e970ff",
"sha256:7bd527f36a605c914efca5d3d014170b2cb184723e423d26b1fb2fd9108e264d",
"sha256:7cb54db3535c8686ea12e9535eb087d32421184eacc6939ef15ef50f83a5e7e2",
"sha256:7f3a2d740291f7f2c111d86a1c4851b70fb000a6c8883a59660d95ad57b9df35",
"sha256:81304b7d8e9c824d058087dcb89144842c8e0dea6d281c031f59f0acf66963d4",
"sha256:933947e8b4fbe617a51528b09851685138b49d511af0b6c0da2539115d6d4514",
"sha256:94223d7f060301b3a8c09c9b3bc3294b56b2188e7d8179c762a1cda72c979252",
"sha256:ab3ca49afcb47058393b0122428358d2fbe0408cf99f1b58b295cfeb4ed39109",
"sha256:bd6292f565ca46dee4e737ebcc20742e3b5be2b01556dafe169f6c65d088875f",
"sha256:cb924aa3e4a3fb644d0c463cad5bc2572649a6a3f68a7f8e4fbe44aaa6d77e4c",
"sha256:d0fc7a286feac9077ec52a927fc9fe8fe2fabab95426722be4c953c9a8bede92",
"sha256:ddc34786490a6e4ec0a855d401034cbd1242ef186c20d79d2166d6a4bd449577",
"sha256:e34b155e36fa9da7e1b7c738ed7767fc9491a62ec6af70fe9da4a057759edc2d",
"sha256:e5b9e8f6bda48460b7b143c3821b21b452cb3a835e6bbd5dd33aa0c8d3f5137d",
"sha256:e81ebf6c5ee9684be8f2c87563880f93eedd56dd2b6146d8a725b50b7e5adb0f",
"sha256:eb91be369f945f10d3a49f5f9be8b3d0b93a4c2be8f8a5b83b0571b8123e0a7a",
"sha256:f460d1ceb0e4a5dcb2a652db0904224f367c9b3c1470d5a7683c0480e582468b"
], ],
"version": "==1.3.1" "markers": "python_version > '2.7'",
}, "version": "==6.0.0"
"mccabe": {
"hashes": [
"sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42",
"sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"
],
"version": "==0.6.1"
}, },
"mypy": { "mypy": {
"hashes": [ "hashes": [
@ -295,13 +224,35 @@
], ],
"version": "==0.4.1" "version": "==0.4.1"
}, },
"pylint": { "pluggy": {
"hashes": [ "hashes": [
"sha256:689de29ae747642ab230c6d37be2b969bf75663176658851f456619aacf27492", "sha256:19ecf9ce9db2fce065a7a0586e07cfb4ac8614fe96edf628a264b1c70116cf8f",
"sha256:771467c434d0d9f081741fec1d64dfb011ed26e65e12a28fe06ca2f61c4d556c" "sha256:84d306a647cc805219916e62aab89caa97a33a1dd8c342e87a37f91073cd4746"
],
"version": "==0.9.0"
},
"py": {
"hashes": [
"sha256:64f65755aee5b381cea27766a3a147c3f15b9b6b9ac88676de66ba2ae36793fa",
"sha256:dc639b046a6e2cff5bbe40194ad65936d6ba360b52b3c3fe1d08a82dd50b5e53"
],
"version": "==1.8.0"
},
"pytest": {
"hashes": [
"sha256:067a1d4bf827ffdd56ad21bd46674703fce77c5957f6c1eef731f6146bfcef1c",
"sha256:9687049d53695ad45cf5fdc7bbd51f0c49f1ea3ecfc4b7f3fde7501b541f17f4"
], ],
"index": "pypi", "index": "pypi",
"version": "==2.2.2" "version": "==4.3.0"
},
"pytest-django": {
"hashes": [
"sha256:30d773f1768e8f214a3106f1090e00300ce6edfcac8c55fd13b675fe1cbd1c85",
"sha256:4d3283e774fe1d40630ee58bf34929b83875e4751b525eeb07a7506996eb42ee"
],
"index": "pypi",
"version": "==3.4.8"
}, },
"six": { "six": {
"hashes": [ "hashes": [
@ -332,14 +283,7 @@
"sha256:d659517ca116e6750101a1326107d3479028c5191f0ecee3c7203c50f5b915b0", "sha256:d659517ca116e6750101a1326107d3479028c5191f0ecee3c7203c50f5b915b0",
"sha256:eddd3fb1f3e0f82e5915a899285a39ee34ce18fd25d89582bc89fc9fb16cd2c6" "sha256:eddd3fb1f3e0f82e5915a899285a39ee34ce18fd25d89582bc89fc9fb16cd2c6"
], ],
"markers": "python_version < '3.7' and implementation_name == 'cpython'",
"version": "==1.3.1" "version": "==1.3.1"
},
"wrapt": {
"hashes": [
"sha256:4aea003270831cceb8a90ff27c4031da6ead7ec1886023b80ce0dfe0adf61533"
],
"version": "==1.11.1"
} }
} }
} }

243
README.md
View file

@ -24,6 +24,7 @@ assignment application for Django, designed to be dropped into an existing site
* jQuery (full version, not "slim", for drag/drop prioritization) * jQuery (full version, not "slim", for drag/drop prioritization)
* Bootstrap (to work with provided templates, though you can override them) * Bootstrap (to work with provided templates, though you can override them)
* bleach (`pip install bleach`) * bleach (`pip install bleach`)
* django-autocomplete-light (optional, required for task merging)
## Overview ## Overview
@ -51,10 +52,9 @@ django-todo is a Django app, not a project site. It needs a site to live in. You
If using your own site, be sure you have jQuery and Bootstrap wired up and working. If using your own site, be sure you have jQuery and Bootstrap wired up and working.
django-todo pages that require it will insert additional CSS/JavaScript into page heads, django-todo views that require it will insert additional CSS/JavaScript into page heads, so your project's base templates must include:
so your project's base templates must include:
``` ```jinja
{% block extrahead %}{% endblock extrahead %} {% block extrahead %}{% endblock extrahead %}
{% block extra_js %}{% endblock extra_js %} {% block extra_js %}{% endblock extra_js %}
``` ```
@ -100,15 +100,19 @@ django-todo makes use of the Django `messages` system. Make sure you have someth
Log in and access `/todo`! Log in and access `/todo`!
### Customizing Templates
The provided templates are fairly bare-bones, and are meant as starting points only. Unlike previous versions of django-todo, they now ship as Bootstrap examples, but feel free to override them - there is no hard dependency on Bootstrap. To override a template, create a `todo` folder in your project's `templates` dir, then copy the template you want to override from django-todo source and into that dir. The provided templates are fairly bare-bones, and are meant as starting points only. Unlike previous versions of django-todo, they now ship as Bootstrap examples, but feel free to override them - there is no hard dependency on Bootstrap. To override a template, create a `todo` folder in your project's `templates` dir, then copy the template you want to override from django-todo source and into that dir.
### Filing Public Tickets
If you wish to use the public ticket-filing system, first create the list into which those tickets should be filed, then add its slug to `TODO_DEFAULT_LIST_SLUG` in settings (more on settings below). If you wish to use the public ticket-filing system, first create the list into which those tickets should be filed, then add its slug to `TODO_DEFAULT_LIST_SLUG` in settings (more on settings below).
## Settings ## Settings
Optional configuration options: Optional configuration params, which can be added to your project settings:
``` ```python
# Restrict access to ALL todo lists/views to `is_staff` users. # Restrict access to ALL todo lists/views to `is_staff` users.
# If False or unset, all users can see all views (but more granular permissions are still enforced # If False or unset, all users can see all views (but more granular permissions are still enforced
# within views, such as requiring staff for adding and deleting lists). # within views, such as requiring staff for adding and deleting lists).
@ -127,12 +131,162 @@ TODO_DEFAULT_LIST_SLUG = 'tickets'
# Defaults to "/" # Defaults to "/"
TODO_PUBLIC_SUBMIT_REDIRECT = 'dashboard' TODO_PUBLIC_SUBMIT_REDIRECT = 'dashboard'
# additionnal classes the comment body should hold
# adding "text-monospace" makes comment monospace
TODO_COMMENT_CLASSES = []
# The following two settings are relevant only if you want todo to track a support mailbox -
# see Mail Tracking below.
TODO_MAIL_BACKENDS
TODO_MAIL_TRACKERS
``` ```
The current django-todo version number is available from the [todo package](https://github.com/shacker/django-todo/blob/master/todo/__init__.py): The current django-todo version number is available from the [todo package](https://github.com/shacker/django-todo/blob/master/todo/__init__.py):
python -c "import todo; print(todo.__version__)" python -c "import todo; print(todo.__version__)"
## Importing Tasks via CSV
django-todo has the ability to batch-import ("upsert") tasks from a specifically formatted CSV spreadsheet. This ability is provided through both a management command and a web interface.
**Management Command**
`./manage.py import_csv -f /path/to/file.csv`
**Web Importer**
Link from your navigation to `{url "todo:import_csv"}`
### CSV Formatting
Copy `todo/data/import_example.csv` to another location on your system and edit in a spreadsheet or directly.
**Do not edit the header row!**
The first four columns: `'Title', 'Group', 'Task List', 'Created By'` are required -- all others are optional and should work pretty much exactly like manual task entry via the web UI.
Note: Internally, Tasks are keyed to TaskLists, not to Groups (TaskLists are in Gruops). However, we request the Group in the CSV
because it's possible to have multiple TaskLists with the same name in different groups; i.e. we need it for namespacing and permissions.
### Import Rules
Because data entered via CSV is not going through the same view permissions enforced in the rest of django-todo, and to simplify data dependency logic, and to pre-empt disagreements between django-todo users, the importer will *not* create new users, groups, or task lists. All users, groups, and task lists referenced in your CSV must already exist, and group memberships must be correct.
Any validation error (e.g. unparse-able dates, incorrect group memberships) **will result in that row being skipped.**
A report of rows upserted and rows skipped (with line numbers and reasons) is provided at the end of the run.
### Upsert Logic
For each valid row, we need to decide whether to create a new task or update an existing one. django-todo matches on the unique combination of the four required columns. If we find a task that matches those, we *update* the rest of the columns. In other words, if you import a CSV once, then edit the Assigned To for a task and import it again, the original task will be updated with a new assignee (and same for the other columns).
Otherwise we create a new task.
## Mail Tracking
What if you could turn django-todo into a shared mailbox? Django-todo includes an optional feature that allows emails
sent to a dedicated mailbox to be pushed into todo as new tasks, and responses to be added as comments on those tasks.
This allows support teams to work with a fully unified email + bug tracking system to avoid confusion over who's seen or
responded to what.
To enable mail tracking, you need to:
- Define an email backend for outgoing emails
- Define an email backend for incoming emails
- Start a worker, which will wait for new emails
In settings:
```python
from todo.mail.producers import imap_producer
from todo.mail.consumers import tracker_consumer
from todo.mail.delivery import smtp_backend, console_backend
# email notifications configuration
# each task list can get its own delivery method
TODO_MAIL_BACKENDS = {
# mail-queue is the name of the task list, not the worker name
"mail-queue": smtp_backend(
host="smtp.example.com",
port=465,
use_ssl=True,
username="test@example.com",
password="foobar",
# used as the From field when sending notifications.
# a username might be prepended later on
from_address="test@example.com",
# additionnal headers
headers={}
),
}
# incoming mail worker configuration
TODO_MAIL_TRACKERS = {
# configuration for worker "test_tracker"
"test_tracker": {
"producer": imap_producer(
host="imap.example.com",
username="text@example.com",
password="foobar",
# process_all=False, # by default, only unseen emails are processed
# preserve=False, # delete emails if False
# nap_duration=1, # duration of the pause between polling rounds
# input_folder="INBOX", # where to read emails from
),
"consumer": tracker_consumer(
group="Mail Queuers",
task_list_slug="mail-queue",
priority=1,
task_title_format="[TEST_MAIL] {subject}",
)
}
}
```
A mail worker can be started with:
```sh
./manage.py mail_worker test_tracker
```
Some views and URLs were renamed in 2.0 for logical consistency. If this affects you, see source code and the demo GTD site for reference to the new URL names.
If you want to log mail events, make sure to properly configure django logging:
```python
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'handlers': {
'console': {
'class': 'logging.StreamHandler',
},
},
'loggers': {
'': {
'handlers': ['console'],
'level': 'DEBUG',
'propagate': True,
},
},
}
```
## Running Tests
django-todo uses pytest exclusively for testing. The best way to run the suite is to clone django-todo into its own directory, install pytest, then:
pip install pytest pytest-django
pip install --editable .
pytest -x -v
The previous `tox` system was removed with the v2 release, since we no longer aim to support older Python or Django versions.
## Upgrade Notes ## Upgrade Notes
django-todo 2.0 was rebuilt almost from the ground up, and included some radical changes, including model name changes. As a result, it is *not compatible* with data from django-todo 1.x. If you would like to upgrade an existing installation, try this: django-todo 2.0 was rebuilt almost from the ground up, and included some radical changes, including model name changes. As a result, it is *not compatible* with data from django-todo 1.x. If you would like to upgrade an existing installation, try this:
@ -151,62 +305,13 @@ That was the plan, but unfortunately, `makemigrations` created new tables and dr
django-todo no longer references a jQuery datepicker, but defaults to native html5 browser datepicker (not supported by Safari, unforunately). Feel free to implement one of your choosing. django-todo no longer references a jQuery datepicker, but defaults to native html5 browser datepicker (not supported by Safari, unforunately). Feel free to implement one of your choosing.
### URLs
Some views and URLs were renamed in 2.0 for logical consistency. If this affects you, see source code and the demo GTD site for reference to the new URL names.
## Running Tests
django-todo uses pytest exclusively for testing. The best way to run the suite is to clone django-todo into its own directory, install pytest, then:
pip install pytest pytest-django
pip install --editable .
pytest -x -v
The previous `tox` system was removed with the v2 release, since we no longer aim to support older Python or Django versions.
## Importing Tasks via CSV
django-todo has the ability to batch-import ("upsert") tasks from a specifically formatted CSV spreadsheet. This ability is provided through both a management command and a web interface.
**Management Command**
`./manage.py import_csv -f /path/to/file.csv`
**Web Importer**
Link from your navigation to `{url "todo:import_csv"}`
### Import Rules
Because data entered via CSV is not going through the same view permissions enforced in the rest of django-todo, and to simplify data dependency logic, and to pre-empt disagreements between django-todo users, the importer will *not* create new users, groups, or task lists. All users, groups, and task lists referenced in your CSV must already exist, and group memberships must be correct.
Any validation error (e.g. unparse-able dates, incorrect group memberships) **will result in that row being skipped.**
A report of rows upserted and rows skipped (with line numbers and reasons) is provided at the end of the run.
### CSV Formatting
Copy `todo/data/import_example.csv` to another location on your system and edit in a spreadsheet or directly.
**Do not edit the header row!**
The first four columns: `'Title', 'Group', 'Task List', 'Created By'` are required -- all others are optional and should work pretty much exactly like manual task entry via the web UI.
Note: Internally, Tasks are keyed to TaskLists, not to Groups (TaskLists are in Gruops). However, we request the Group in the CSV
because it's possible to have multiple TaskLists with the same name in different groups; i.e. we need it for namespacing and permissions.
### Upsert Logic
For each valid row, we need to decide whether to create a new task or update an existing one. django-todo matches on the unique combination of the four required columns. If we find a task that matches those, we *update* the rest of the columns. In other words, if you import a CSV once, then edit the Assigned To for a task and import it again, the original task will be updated with a new assignee (and same for the other columns).
Otherwise we create a new task.
## Version History ## Version History
**2.3.0** Added ability to batch-import tasks via CSV **2.4.0** Added ability to batch-import tasks via CSV
**2.3.0** Implement mail tracking system
**2.2.2** Update dependencies
**2.2.1** Convert task delete and toggle_done views to POST only **2.2.1** Convert task delete and toggle_done views to POST only
@ -268,4 +373,24 @@ ALL groups, not just the groups they "belong" to)
**0.9** - First release **0.9** - First release
## Todo 2.0 Upgrade Notes
django-todo 2.0 was rebuilt almost from the ground up, and included some radical changes, including model name changes. As a result, it is *not compatible* with data from django-todo 1.x. If you would like to upgrade an existing installation, try this:
* Use `./manage.py dumpdata todo --indent 4 > todo.json` to export your old todo data
* Edit the dump file, replacing the old model names `Item` and `List` with the new model names (`Task` and `TaskList`)
* Delete your existing todo data
* Uninstall the old todo app and reinstall
* Migrate, then use `./manage.py loaddata todo.json` to import the edited data
### Why not provide migrations?
That was the plan, but unfortunately, `makemigrations` created new tables and dropped the old ones, making this a destructive update. Renaming models is unfortunately not something `makemigrations` can do, and I really didn't want to keep the badly named original models. Sorry!
### Datepicker
django-todo no longer references a jQuery datepicker, but defaults to native html5 browser datepicker (not supported by Safari, unforunately). Feel free to implement one of your choosing.
### URLs
Some views and URLs were renamed for logical consistency. If this affects you, see source code and the demo GTD site for reference to the new URL names.

View file

@ -29,6 +29,8 @@ INSTALLED_APPS = (
"django.contrib.sites", "django.contrib.sites",
"django.contrib.staticfiles", "django.contrib.staticfiles",
"todo", "todo",
"dal",
"dal_select2",
) )
ROOT_URLCONF = "base_urls" ROOT_URLCONF = "base_urls"
@ -61,3 +63,30 @@ TEMPLATES = [
}, },
} }
] ]
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'handlers': {
'console': {
'class': 'logging.StreamHandler',
},
},
'loggers': {
'': {
'handlers': ['console'],
'level': 'DEBUG',
'propagate': True,
},
'django': {
'handlers': ['console'],
'level': 'DEBUG',
'propagate': True,
},
'django.request': {
'handlers': ['console'],
'level': 'DEBUG',
'propagate': True,
},
},
}

View file

@ -1,10 +1,12 @@
""" """
A multi-user, multi-group task management and assignment system for Django. A multi-user, multi-group task management and assignment system for Django.
""" """
__version__ = '2.2.1' __version__ = '2.3.0'
__author__ = 'Scot Hacker' __author__ = 'Scot Hacker'
__email__ = 'shacker@birdhouse.org' __email__ = 'shacker@birdhouse.org'
__url__ = 'https://github.com/shacker/django-todo' __url__ = 'https://github.com/shacker/django-todo'
__license__ = 'BSD License' __license__ = 'BSD License'
from . import check

19
todo/check.py Normal file
View file

@ -0,0 +1,19 @@
from django.core.checks import Error, register
# the sole purpose of this warning is to prevent people who have
# django-autocomplete-light installed but not configured to start the app
@register()
def dal_check(app_configs, **kwargs):
from django.conf import settings
from todo.features import HAS_AUTOCOMPLETE
if not HAS_AUTOCOMPLETE:
return []
errors = []
missing_apps = {'dal', 'dal_select2'} - set(settings.INSTALLED_APPS)
for missing_app in missing_apps:
errors.append(
Error('{} needs to be in INSTALLED_APPS'.format(missing_app))
)
return errors

11
todo/features.py Normal file
View file

@ -0,0 +1,11 @@
HAS_AUTOCOMPLETE = True
try:
import dal
except ImportError:
HAS_AUTOCOMPLETE = False
HAS_TASK_MERGE = False
if HAS_AUTOCOMPLETE:
import dal.autocomplete
if getattr(dal.autocomplete, 'Select2QuerySetView', None) is not None:
HAS_TASK_MERGE = True

0
todo/mail/__init__.py Normal file
View file

View file

@ -0,0 +1,9 @@
def tracker_consumer(**kwargs):
def tracker_factory(producer):
# the import needs to be delayed until call to enable
# using the wrapper in the django settings
from .tracker import tracker_consumer
return tracker_consumer(producer, **kwargs)
return tracker_factory

View file

@ -0,0 +1,150 @@
import re
import logging
from email.charset import Charset as EMailCharset
from django.db import transaction
from django.db.models import Count
from html2text import html2text
from todo.models import Comment, Task, TaskList
logger = logging.getLogger(__name__)
def part_decode(message):
charset = ("ascii", "ignore")
email_charset = message.get_content_charset()
if email_charset:
charset = (EMailCharset(email_charset).input_charset,)
body = message.get_payload(decode=True)
return body.decode(*charset)
def message_find_mime(message, mime_type):
for submessage in message.walk():
if submessage.get_content_type() == mime_type:
return submessage
return None
def message_text(message):
text_part = message_find_mime(message, "text/plain")
if text_part is not None:
return part_decode(text_part)
html_part = message_find_mime(message, "text/html")
if html_part is not None:
return html2text(part_decode(html_part))
# TODO: find something smart to do when no text if found
return ""
def format_task_title(format_string, message):
return format_string.format(
subject=message["subject"],
author=message["from"],
)
DJANGO_TODO_THREAD = re.compile(r'<thread-(\d+)@django-todo>')
def parse_references(task_list, references):
related_messages = []
answer_thread = None
for related_message in references.split():
logger.info("checking reference: %r", related_message)
match = re.match(DJANGO_TODO_THREAD, related_message)
if match is None:
related_messages.append(related_message)
continue
thread_id = int(match.group(1))
new_answer_thread = Task.objects.filter(
task_list=task_list,
pk=thread_id
).first()
if new_answer_thread is not None:
answer_thread = new_answer_thread
if answer_thread is None:
logger.info("no answer thread found in references")
else:
logger.info("found an answer thread: %d", answer_thread)
return related_messages, answer_thread
def insert_message(task_list, message, priority, task_title_format):
if "message-id" not in message:
logger.warning("missing message id, ignoring message")
return
if "from" not in message:
logger.warning('missing "From" header, ignoring message')
return
if "subject" not in message:
logger.warning('missing "Subject" header, ignoring message')
return
logger.info(
"received message:\t"
f"[Subject: {message['subject']}]\t"
f"[Message-ID: {message['message-id']}]\t"
f"[References: {message['references']}]\t"
f"[To: {message['to']}]\t"
f"[From: {message['from']}]"
)
message_id = message["message-id"]
message_from = message["from"]
text = message_text(message)
related_messages, answer_thread = \
parse_references(task_list, message.get("references", ""))
# find the most relevant task to add a comment on.
# among tasks in the selected task list, find the task having the
# most email comments the current message references
best_task = (
Task.objects.filter(
task_list=task_list, comment__email_message_id__in=related_messages
)
.annotate(num_comments=Count("comment"))
.order_by("-num_comments")
.only("id")
.first()
)
# if no related comment is found but a thread message-id
# (generated by django-todo) could be found, use it
if best_task is None and answer_thread is not None:
best_task = answer_thread
with transaction.atomic():
if best_task is None:
best_task = Task.objects.create(
priority=priority,
title=format_task_title(task_title_format, message),
task_list=task_list
)
logger.info("using task: %r", best_task)
comment, comment_created = Comment.objects.get_or_create(
task=best_task,
email_message_id=message_id,
defaults={"email_from": message_from, "body": text},
)
logger.info("created comment: %r", comment)
def tracker_consumer(producer, group=None, task_list_slug=None,
priority=1, task_title_format="[MAIL] {subject}"):
task_list = TaskList.objects.get(group__name=group, slug=task_list_slug)
for message in producer:
try:
insert_message(task_list, message, priority, task_title_format)
except Exception:
# ignore exceptions during insertion, in order to avoid
logger.exception("got exception while inserting message")

25
todo/mail/delivery.py Normal file
View file

@ -0,0 +1,25 @@
import importlib
def _declare_backend(backend_path):
backend_path = backend_path.split('.')
backend_module_name = '.'.join(backend_path[:-1])
class_name = backend_path[-1]
def backend(*args, headers={}, from_address=None, **kwargs):
def _backend():
backend_module = importlib.import_module(backend_module_name)
backend = getattr(backend_module, class_name)
return backend(*args, **kwargs)
if from_address is None:
raise ValueError("missing from_address")
_backend.from_address = from_address
_backend.headers = headers
return _backend
return backend
smtp_backend = _declare_backend('django.core.mail.backends.smtp.EmailBackend')
console_backend = _declare_backend('django.core.mail.backends.console.EmailBackend')
locmem_backend = _declare_backend('django.core.mail.backends.locmem.EmailBackend')

View file

@ -0,0 +1,9 @@
def imap_producer(**kwargs):
def imap_producer_factory():
# the import needs to be delayed until call to enable
# using the wrapper in the django settings
from .imap import imap_producer
return imap_producer(**kwargs)
return imap_producer_factory

100
todo/mail/producers/imap.py Normal file
View file

@ -0,0 +1,100 @@
import email
import email.parser
import imaplib
import logging
import time
from email.policy import default
from contextlib import contextmanager
logger = logging.getLogger(__name__)
def imap_check(command_tuple):
status, ids = command_tuple
assert status == "OK", ids
@contextmanager
def imap_connect(host, port, username, password):
conn = imaplib.IMAP4_SSL(host=host, port=port)
conn.login(username, password)
imap_check(conn.list())
try:
yield conn
finally:
conn.close()
def parse_message(message):
for response_part in message:
if not isinstance(response_part, tuple):
continue
message_metadata, message_content = response_part
email_parser = email.parser.BytesFeedParser(policy=default)
email_parser.feed(message_content)
return email_parser.close()
def search_message(conn, *filters):
status, message_ids = conn.search(None, *filters)
for message_id in message_ids[0].split():
status, message = conn.fetch(message_id, "(RFC822)")
yield message_id, parse_message(message)
def imap_producer(
process_all=False,
preserve=False,
host=None,
port=993,
username=None,
password=None,
nap_duration=1,
input_folder="INBOX",
):
logger.debug("starting IMAP worker")
imap_filter = "(ALL)" if process_all else "(UNSEEN)"
def process_batch():
logger.debug("starting to process batch")
# reconnect each time to avoid repeated failures due to a lost connection
with imap_connect(host, port, username, password) as conn:
# select the requested folder
imap_check(conn.select(input_folder, readonly=False))
try:
for message_uid, message in search_message(conn, imap_filter):
logger.info(f"received message {message_uid}")
try:
yield message
except Exception:
logger.exception(
f"something went wrong while processing {message_uid}"
)
raise
if not preserve:
# tag the message for deletion
conn.store(message_uid, '+FLAGS', '\\Deleted')
else:
logger.debug("did not receive any message")
finally:
if not preserve:
# flush deleted messages
conn.expunge()
while True:
try:
yield from process_batch()
except (GeneratorExit, KeyboardInterrupt):
# the generator was closed, due to the consumer
# breaking out of the loop, or an exception occuring
raise
except Exception:
logger.exception("mail fetching went wrong, retrying")
# sleep to avoid using too much resources
# TODO: get notified when a new message arrives
time.sleep(nap_duration)

View file

@ -0,0 +1,44 @@
import logging
import socket
import sys
from django.core.management.base import BaseCommand
from django.conf import settings
logger = logging.getLogger(__name__)
DEFAULT_IMAP_TIMEOUT = 20
class Command(BaseCommand):
help = "Starts a mail worker"
def add_arguments(self, parser):
parser.add_argument("--imap_timeout", type=int, default=30)
parser.add_argument("worker_name")
def handle(self, *args, **options):
if not hasattr(settings, "TODO_MAIL_TRACKERS"):
logger.error("missing TODO_MAIL_TRACKERS setting")
sys.exit(1)
worker_name = options["worker_name"]
tracker = settings.TODO_MAIL_TRACKERS.get(worker_name, None)
if tracker is None:
logger.error(
"couldn't find configuration for %r in TODO_MAIL_TRACKERS",
worker_name
)
sys.exit(1)
# set the default socket timeout (imaplib doesn't enable configuring it)
timeout = options["imap_timeout"]
if timeout:
socket.setdefaulttimeout(timeout)
# run the mail polling loop
producer = tracker["producer"]
consumer = tracker["consumer"]
consumer(producer())

View file

@ -0,0 +1,46 @@
# Generated by Django 2.1.4 on 2018-12-21 14:01
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [("todo", "0007_auto_update_created_date")]
operations = [
migrations.AddField(
model_name="comment",
name="email_from",
field=models.CharField(blank=True, max_length=320, null=True),
),
migrations.AddField(
model_name="comment",
name="email_message_id",
field=models.TextField(blank=True, null=True),
),
migrations.AlterField(
model_name="comment",
name="author",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
migrations.AlterField(
model_name="task",
name="created_by",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="todo_created_by",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AlterUniqueTogether(
name="comment", unique_together={("task", "email_message_id")}
),
]

View file

@ -1,13 +1,50 @@
from __future__ import unicode_literals from __future__ import unicode_literals
import datetime import datetime
import textwrap
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
from django.db import models from django.db import models, DEFAULT_DB_ALIAS
from django.db.transaction import Atomic, get_connection
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
class LockedAtomicTransaction(Atomic):
"""
modified from https://stackoverflow.com/a/41831049
this is needed for safely merging
Does a atomic transaction, but also locks the entire table for any transactions, for the duration of this
transaction. Although this is the only way to avoid concurrency issues in certain situations, it should be used with
caution, since it has impacts on performance, for obvious reasons...
"""
def __init__(self, *models, using=None, savepoint=None):
if using is None:
using = DEFAULT_DB_ALIAS
super().__init__(using, savepoint)
self.models = models
def __enter__(self):
super(LockedAtomicTransaction, self).__enter__()
# Make sure not to lock, when sqlite is used, or you'll run into problems while running tests!!!
if settings.DATABASES[self.using]["ENGINE"] != "django.db.backends.sqlite3":
cursor = None
try:
cursor = get_connection(self.using).cursor()
for model in self.models:
cursor.execute(
"LOCK TABLE {table_name}".format(
table_name=model._meta.db_table
)
)
finally:
if cursor and not cursor.closed:
cursor.close()
class TaskList(models.Model): class TaskList(models.Model):
name = models.CharField(max_length=60) name = models.CharField(max_length=60)
slug = models.SlugField(default="") slug = models.SlugField(default="")
@ -32,7 +69,10 @@ class Task(models.Model):
completed = models.BooleanField(default=False) completed = models.BooleanField(default=False)
completed_date = models.DateField(blank=True, null=True) completed_date = models.DateField(blank=True, null=True)
created_by = models.ForeignKey( created_by = models.ForeignKey(
settings.AUTH_USER_MODEL, related_name="todo_created_by", on_delete=models.CASCADE settings.AUTH_USER_MODEL,
null=True,
related_name="todo_created_by",
on_delete=models.CASCADE,
) )
assigned_to = models.ForeignKey( assigned_to = models.ForeignKey(
settings.AUTH_USER_MODEL, settings.AUTH_USER_MODEL,
@ -63,6 +103,17 @@ class Task(models.Model):
self.completed_date = datetime.datetime.now() self.completed_date = datetime.datetime.now()
super(Task, self).save() super(Task, self).save()
def merge_into(self, merge_target):
if merge_target.pk == self.pk:
raise ValueError("can't merge a task with self")
# lock the comments to avoid concurrent additions of comments after the
# update request. these comments would be irremediably lost because of
# the cascade clause
with LockedAtomicTransaction(Comment):
Comment.objects.filter(task=self).update(task=merge_target)
self.delete()
class Meta: class Meta:
ordering = ["priority"] ordering = ["priority"]
@ -73,14 +124,35 @@ class Comment(models.Model):
a comment and change task details at the same time. Rolling our own since it's easy. a comment and change task details at the same time. Rolling our own since it's easy.
""" """
author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) author = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, blank=True, null=True
)
task = models.ForeignKey(Task, on_delete=models.CASCADE) task = models.ForeignKey(Task, on_delete=models.CASCADE)
date = models.DateTimeField(default=datetime.datetime.now) date = models.DateTimeField(default=datetime.datetime.now)
email_from = models.CharField(max_length=320, blank=True, null=True)
email_message_id = models.TextField(blank=True, null=True)
body = models.TextField(blank=True) body = models.TextField(blank=True)
class Meta:
# an email should only appear once per task
unique_together = ("task", "email_message_id")
@property
def author_text(self):
if self.author is not None:
return str(self.author)
assert self.email_message_id is not None
return str(self.email_from)
@property
def snippet(self): def snippet(self):
body_snippet = textwrap.shorten(self.body, width=35, placeholder="...")
# Define here rather than in __str__ so we can use it in the admin list_display # Define here rather than in __str__ so we can use it in the admin list_display
return "{author} - {snippet}...".format(author=self.author, snippet=self.body[:35]) return "{author} - {snippet}...".format(
author=self.author_text, snippet=body_snippet
)
def __str__(self): def __str__(self):
return self.snippet() return self.snippet

View file

@ -2,9 +2,7 @@
<form action="" name="add_task" method="post"> <form action="" name="add_task" method="post">
{% csrf_token %} {% csrf_token %}
<div class="mt-3">
<div id="AddEditTask" class="collapse mt-3">
<div class="form-group"> <div class="form-group">
<label for="id_title" name="title">Task</label> <label for="id_title" name="title">Task</label>
<input type="text" class="form-control" id="id_title" name="title" required placeholder="Task title" <input type="text" class="form-control" id="id_title" name="title" required placeholder="Task title"
@ -33,12 +31,15 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="id_notify">Notify</label> <div class="form-check">
<input type="checkbox" checked="checked" class="form-control" id="id_notify" name="notify" aria-describedby="inputNotifyHelp" <input class="form-check-input" type="checkbox" aria-describedby="inputNotifyHelp" checked="checked" id="id_notify">
value="{{ form.notify.text }}"> <label class="form-check-label" for="id_notify">
<small id="inputNotifyHelp" class="form-text text-muted"> Notify
Email notifications will only be sent if task is assigned to someone other than yourself. </label>
</small> <small id="inputNotifyHelp" class="form-text text-muted">
Email notifications will only be sent if task is assigned to someone other than yourself.
</small>
</div>
</div> </div>
<input type="hidden" name="priority" <input type="hidden" name="priority"

View file

@ -10,7 +10,9 @@
data-toggle="collapse" data-target="#AddEditTask">Add Task</button> data-toggle="collapse" data-target="#AddEditTask">Add Task</button>
{# Task edit / new task form #} {# Task edit / new task form #}
{% include 'todo/include/task_edit.html' %} <div id="AddEditTask" class="collapse">
{% include 'todo/include/task_edit.html' %}
</div>
<hr /> <hr />
{% endif %} {% endif %}

View file

@ -2,50 +2,63 @@
{% block title %}Task:{{ task.title }}{% endblock %} {% block title %}Task:{{ task.title }}{% endblock %}
{% block extrahead %}
<style>
.select2 {
width: 100% !important;
}
.select2-container {
min-width: 0 !important;
}
</style>
{{ form.media }}
{{ merge_form.media }}
{% endblock %}
{% block content %} {% block content %}
<div class="row"> <div class="card-deck">
<div class="col-sm-8"> <div class="card col-sm-8">
<h3 class="card-title">{{ task.title }}</h3> <div class="card-body">
{% if task.note %} <h3 class="card-title">{{ task.title }}</h3>
<p class="card-text">{{ task.note|safe|urlize|linebreaks }}</p> {% if task.note %}
{% endif %} <div class="card-text">{{ task.note|safe|urlize|linebreaks }}</div>
{% endif %}
</div>
</div> </div>
<div class="col-sm-4"> <div class="card col-sm-4 p-0">
<div class="mb-2"> <ul class="list-group list-group-flush">
<li class="list-group-item">
<button <button
class="btn btn-sm btn-primary" class="btn btn-sm btn-primary"
id="EditTaskButton" id="EditTaskButton"
type="button" type="button"
data-toggle="collapse" data-toggle="collapse"
data-target="#AddEditTask" data-target="#TaskEdit">
>
Edit Task Edit Task
</button> </button>
<form method="post" action="{% url "todo:task_toggle_done" task.id %}" role="form" style="display:inline;"> <form method="post" action="{% url "todo:task_toggle_done" task.id %}" role="form" class="d-inline">
{% csrf_token %} {% csrf_token %}
<div style="display:inline;"> <div style="display:inline;">
<button class="btn btn-info btn-sm" type="submit" name="toggle_done"> <button class="btn btn-info btn-sm" type="submit" name="toggle_done">
{% if task.completed %} Mark Not Done {% else %} Mark Done {% endif %} {% if task.completed %} Mark Not Done {% else %} Mark Done {% endif %}
</button> </button>
</div> </div>
</form> </form>
<form method="post" action="{% url "todo:delete_task" task.id %}" role="form" style="display:inline;">
{% csrf_token %}
<div style="display:inline;">
<button class="btn btn-danger btn-sm" type="submit" name="submit_delete">
Delete
</button>
</div>
</form>
</div>
<ul class="list-group">
<form method="post" action="{% url "todo:delete_task" task.id %}" role="form" class="d-inline">
{% csrf_token %}
<div style="display:inline;">
<button class="btn btn-danger btn-sm" type="submit" name="submit_delete">
Delete
</button>
</div>
</form>
</li>
<li class="list-group-item"> <li class="list-group-item">
<strong>Assigned to:</strong> <strong>Assigned to:</strong>
{% if task.assigned_to %} {{ task.assigned_to.get_full_name }} {% else %} Anyone {% endif %} {% if task.assigned_to %} {{ task.assigned_to.get_full_name }} {% else %} Anyone {% endif %}
@ -77,35 +90,65 @@
</div> </div>
</div> </div>
<div id="TaskEdit"> <div id="TaskEdit" class="collapse">
{# Task edit / new task form #} {# Task edit / new task form #}
{% include 'todo/include/task_edit.html' %} {% include 'todo/include/task_edit.html' %}
{% if merge_form is not None %}
<form action="" method="post">
<div class="card border-danger">
<div class="card-header">Merge task</div>
<div class="card-body">
<div class="">
<p>Merging is a destructive operation. This task will not exist anymore, and comments will be moved to the target task.</p>
{% csrf_token %}
{% for field in merge_form.visible_fields %}
<p>
{{ field.errors }}
{{ field }}
</p>
{% endfor %}
<input class="d-inline btn btn-sm btn-outline-danger" type="submit" name="merge_task_into" value="Merge">
</div>
</div>
</div>
</form>
{% endif %}
</div> </div>
<h5>Add comment</h5> <div class="mt-3">
<form action="" method="post"> <h5>Add comment</h5>
{% csrf_token %} <form action="" method="post">
<div class="form-group"> {% csrf_token %}
<textarea class="form-control" name="comment-body" rows="3"></textarea> <div class="form-group">
</div> <textarea class="form-control" name="comment-body" rows="3"></textarea>
<input class="btn btn-sm btn-primary" type="submit" name="add_comment" value="Add Comment"> </div>
</form> <input class="btn btn-sm btn-primary" type="submit" name="add_comment" value="Add Comment">
</form>
</div>
<div class="task_comments mt-4"> <div class="task_comments mt-4">
{% if comment_list %} {% if comment_list %}
<h5>Comments on this task</h5> <h5>Comments on this task</h5>
{% for comment in comment_list %} {% for comment in comment_list %}
<p> <div class="mb-3 card">
<strong>{{ comment.author.first_name }} <div class="card-header">
{{ comment.author.last_name }}, <div class="float-left">
{% if comment.email_message_id %}
<span class="badge badge-warning">email</span>
{% endif %}
{{ comment.author_text }}
</div>
<span class="float-right d-inline-block text-muted">
{{ comment.date|date:"F d Y P" }} {{ comment.date|date:"F d Y P" }}
</strong> </span>
</p> </div>
{{ comment.body|safe|urlize|linebreaks }} <div class="{{ comment_classes | join:" " }} card-body">
{{ comment.body|safe|urlize|linebreaks }}
</div>
</div>
{% endfor %} {% endfor %}
{% else %} {% else %}
<h5>No comments (yet).</h5> <h5>No comments (yet).</h5>
{% endif %} {% endif %}
</div> </div>
{% endblock %} {% endblock %}

View file

@ -28,3 +28,9 @@ def todo_setup(django_user_model):
Task.objects.create(created_by=u2, title="Task 1", task_list=tlist2, priority=1) Task.objects.create(created_by=u2, title="Task 1", task_list=tlist2, priority=1)
Task.objects.create(created_by=u2, title="Task 2", task_list=tlist2, priority=2, completed=True) Task.objects.create(created_by=u2, title="Task 2", task_list=tlist2, priority=2, completed=True)
Task.objects.create(created_by=u2, title="Task 3", task_list=tlist2, priority=3) Task.objects.create(created_by=u2, title="Task 3", task_list=tlist2, priority=3)
@pytest.fixture()
# Set up an in-memory mail server to receive test emails
def email_backend_setup(settings):
settings.EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"

View file

@ -0,0 +1,67 @@
import pytest
from django.core import mail
from todo.models import Task, Comment
from todo.mail.consumers import tracker_consumer
from email.message import EmailMessage
def consumer(*args, title_format="[TEST] {subject}", **kwargs):
return tracker_consumer(
group="Workgroup One",
task_list_slug="zip",
priority=1,
task_title_format=title_format,
)(*args, **kwargs)
def make_message(subject, content):
msg = EmailMessage()
msg.set_content(content)
msg['Subject'] = subject
return msg
def test_tracker_task_creation(todo_setup, django_user_model):
msg = make_message("test1 subject", "test1 content")
msg['From'] = 'test1@example.com'
msg['Message-ID'] = '<a@example.com>'
# test task creation
task_count = Task.objects.count()
consumer([msg])
assert task_count + 1 == Task.objects.count(), "task wasn't created"
task = Task.objects.filter(title="[TEST] test1 subject").first()
assert task is not None, "task was created with the wrong name"
# test thread answers
msg = make_message("test2 subject", "test2 content")
msg['From'] = 'test1@example.com'
msg['Message-ID'] = '<b@example.com>'
msg['References'] = '<nope@example.com> <a@example.com>'
task_count = Task.objects.count()
consumer([msg])
assert task_count == Task.objects.count(), "comment created another task"
Comment.objects.get(
task=task,
body__contains="test2 content",
email_message_id='<b@example.com>'
)
# test notification answer
msg = make_message("test3 subject", "test3 content")
msg['From'] = 'test1@example.com'
msg['Message-ID'] = '<c@example.com>'
msg['References'] = '<thread-{}@django-todo> <unknown@example.com>'.format(task.pk)
task_count = Task.objects.count()
consumer([msg])
assert task_count == Task.objects.count(), "comment created another task"
Comment.objects.get(
task=task,
body__contains="test3 content",
email_message_id='<c@example.com>'
)

View file

@ -6,12 +6,6 @@ from todo.models import Task, Comment
from todo.utils import send_notify_mail, send_email_to_thread_participants from todo.utils import send_notify_mail, send_email_to_thread_participants
@pytest.fixture()
# Set up an in-memory mail server to receive test emails
def email_backend_setup(settings):
settings.EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"
def test_send_notify_mail_not_me(todo_setup, django_user_model, email_backend_setup): def test_send_notify_mail_not_me(todo_setup, django_user_model, email_backend_setup):
"""Assign a task to someone else, mail should be sent. """Assign a task to someone else, mail should be sent.
TODO: Future tests could check for email contents. TODO: Future tests could check for email contents.

View file

@ -1,11 +1,12 @@
from django.conf import settings
from django.urls import path from django.urls import path
from todo import views from todo import views
from todo.features import HAS_TASK_MERGE
app_name = 'todo' app_name = 'todo'
urlpatterns = [ urlpatterns = [
path( path(
'', '',
views.list_lists, views.list_lists,
@ -55,7 +56,19 @@ urlpatterns = [
'task/<int:task_id>/', 'task/<int:task_id>/',
views.task_detail, views.task_detail,
name='task_detail'), name='task_detail'),
]
if HAS_TASK_MERGE:
# ensure mail tracker autocomplete is optional
from todo.views.task_autocomplete import TaskAutocomplete
urlpatterns.append(
path(
'task/<int:task_id>/autocomplete/',
TaskAutocomplete.as_view(),
name='task_autocomplete')
)
urlpatterns.extend([
path( path(
'toggle_done/<int:task_id>/', 'toggle_done/<int:task_id>/',
views.toggle_done, views.toggle_done,

View file

@ -1,8 +1,12 @@
import email.utils
import functools
import time
from django.conf import settings from django.conf import settings
from django.contrib.sites.models import Site from django.contrib.sites.models import Site
from django.core import mail
from django.core.exceptions import PermissionDenied
from django.core.mail import send_mail from django.core.mail import send_mail
from django.template.loader import render_to_string from django.template.loader import render_to_string
from todo.models import Comment, Task from todo.models import Comment, Task
@ -19,45 +23,134 @@ def staff_check(user):
return True return True
def user_can_read_task(task, user):
return task.task_list.group in user.groups.all() or user.is_staff
def todo_get_backend(task):
'''returns a mail backend for some task'''
mail_backends = getattr(settings, "TODO_MAIL_BACKENDS", None)
if mail_backends is None:
return None
task_backend = mail_backends[task.task_list.slug]
if task_backend is None:
return None
return task_backend
def todo_get_mailer(user, task):
"""a mailer is a (from_address, backend) pair"""
task_backend = todo_get_backend(task)
if task_backend is None:
return (None, mail.get_connection)
from_address = getattr(task_backend, "from_address")
from_address = email.utils.formataddr((user.username, from_address))
return (from_address, task_backend)
def todo_send_mail(user, task, subject, body, recip_list):
'''Send an email attached to task, triggered by user'''
references = Comment.objects.filter(task=task).only('email_message_id')
references = (ref.email_message_id for ref in references)
references = ' '.join(filter(bool, references))
from_address, backend = todo_get_mailer(user, task)
message_hash = hash((
subject,
body,
from_address,
frozenset(recip_list),
references,
))
message_id = (
# the task_id enables attaching back notification answers
"<notif-{task_id}."
# the message hash / epoch pair enables deduplication
"{message_hash:x}."
"{epoch}@django-todo>"
).format(
task_id=task.pk,
# avoid the -hexstring case (hashes can be negative)
message_hash=abs(message_hash),
epoch=int(time.time())
)
# the thread message id is used as a common denominator between all
# notifications for some task. This message doesn't actually exist,
# it's just there to make threading possible
thread_message_id = "<thread-{}@django-todo>".format(task.pk)
references = '{} {}'.format(references, thread_message_id)
with backend() as connection:
message = mail.EmailMessage(
subject,
body,
from_address,
recip_list,
[], # Bcc
headers={
**getattr(backend, 'headers', {}),
'Message-ID': message_id,
'References': references,
'In-reply-to': thread_message_id,
},
connection=connection,
)
message.send()
def send_notify_mail(new_task): def send_notify_mail(new_task):
# Send email to assignee if task is assigned to someone other than submittor. '''
# Unassigned tasks should not try to notify. Send email to assignee if task is assigned to someone other than submittor.
Unassigned tasks should not try to notify.
'''
if not new_task.assigned_to == new_task.created_by: if new_task.assigned_to == new_task.created_by:
current_site = Site.objects.get_current() return
email_subject = render_to_string("todo/email/assigned_subject.txt", {"task": new_task})
email_body = render_to_string(
"todo/email/assigned_body.txt", {"task": new_task, "site": current_site}
)
send_mail( current_site = Site.objects.get_current()
email_subject, subject = render_to_string("todo/email/assigned_subject.txt", {"task": new_task})
email_body, body = render_to_string(
new_task.created_by.email, "todo/email/assigned_body.txt", {"task": new_task, "site": current_site}
[new_task.assigned_to.email], )
fail_silently=False,
) recip_list = [new_task.assigned_to.email]
todo_send_mail(new_task.created_by, new_task, subject, body, recip_list)
def send_email_to_thread_participants(task, msg_body, user, subject=None): def send_email_to_thread_participants(task, msg_body, user, subject=None):
# Notify all previous commentors on a Task about a new comment. '''Notify all previous commentors on a Task about a new comment.'''
current_site = Site.objects.get_current() current_site = Site.objects.get_current()
email_subject = ( email_subject = subject
subject if subject else render_to_string("todo/email/assigned_subject.txt", {"task": task}) if not subject:
) subject = render_to_string(
"todo/email/assigned_subject.txt",
{"task": task}
)
email_body = render_to_string( email_body = render_to_string(
"todo/email/newcomment_body.txt", "todo/email/newcomment_body.txt",
{"task": task, "body": msg_body, "site": current_site, "user": user}, {"task": task, "body": msg_body, "site": current_site, "user": user},
) )
# Get list of all thread participants - everyone who has commented, plus task creator. # Get all thread participants
commenters = Comment.objects.filter(task=task) commenters = Comment.objects.filter(task=task)
recip_list = [ca.author.email for ca in commenters] recip_list = set(
recip_list.append(task.created_by.email) ca.author.email
recip_list = list(set(recip_list)) # Eliminate duplicates for ca in commenters
if ca.author is not None
)
for related_user in (task.created_by, task.assigned_to):
if related_user is not None:
recip_list.add(related_user.email)
recip_list = list(m for m in recip_list if m)
send_mail(email_subject, email_body, task.created_by.email, recip_list, fail_silently=False) todo_send_mail(user, task, email_subject, email_body, recip_list)
def toggle_task_completed(task_id: int) -> bool: def toggle_task_completed(task_id: int) -> bool:

View file

@ -0,0 +1,29 @@
from dal import autocomplete
from django.contrib.auth.decorators import login_required
from django.core.exceptions import PermissionDenied
from django.shortcuts import get_object_or_404
from django.utils.decorators import method_decorator
from todo.models import Task
from todo.utils import user_can_read_task
class TaskAutocomplete(autocomplete.Select2QuerySetView):
@method_decorator(login_required)
def dispatch(self, request, task_id, *args, **kwargs):
self.task = get_object_or_404(Task, pk=task_id)
if not user_can_read_task(self.task, request.user):
raise PermissionDenied
return super().dispatch(request, task_id, *args, **kwargs)
def get_queryset(self):
# Don't forget to filter out results depending on the visitor !
if not self.request.user.is_authenticated:
return Task.objects.none()
qs = Task.objects.filter(task_list=self.task.task_list).exclude(pk=self.task.pk)
if self.q:
qs = qs.filter(title__istartswith=self.q)
return qs

View file

@ -1,15 +1,47 @@
import bleach
import datetime import datetime
import bleach from django import forms
from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.decorators import login_required, user_passes_test from django.contrib.auth.decorators import login_required, user_passes_test
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.http import HttpResponse from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render, redirect
from django.urls import reverse
from django.utils.decorators import method_decorator
from todo.forms import AddEditTaskForm from todo.forms import AddEditTaskForm
from todo.models import Comment, Task from todo.models import Comment, Task
from todo.utils import send_email_to_thread_participants, toggle_task_completed, staff_check from todo.utils import send_email_to_thread_participants, toggle_task_completed, staff_check, user_can_read_task
from todo.features import HAS_TASK_MERGE
if HAS_TASK_MERGE:
from dal import autocomplete
from todo.views.task_autocomplete import TaskAutocomplete
def handle_add_comment(request, task):
if not request.POST.get("add_comment"):
return
Comment.objects.create(
author=request.user,
task=task,
body=bleach.clean(request.POST["comment-body"], strip=True),
)
send_email_to_thread_participants(
task,
request.POST["comment-body"],
request.user,
subject='New comment posted on task "{}"'.format(task.title),
)
messages.success(
request, "Comment posted. Notification email sent to thread participants."
)
@login_required @login_required
@ -19,33 +51,55 @@ def task_detail(request, task_id: int) -> HttpResponse:
""" """
task = get_object_or_404(Task, pk=task_id) task = get_object_or_404(Task, pk=task_id)
comment_list = Comment.objects.filter(task=task_id) comment_list = Comment.objects.filter(task=task_id).order_by('-date')
# Ensure user has permission to view task. Admins can view all tasks. # Ensure user has permission to view task. Admins can view all tasks.
# Get the group this task belongs to, and check whether current user is a member of that group. # Get the group this task belongs to, and check whether current user is a member of that group.
if task.task_list.group not in request.user.groups.all() and not request.user.is_staff: if not user_can_read_task(task, request.user):
raise PermissionDenied raise PermissionDenied
# Save submitted comments # Handle task merging
if request.POST.get("add_comment"): if not HAS_TASK_MERGE:
Comment.objects.create( merge_form = None
author=request.user, else:
task=task, class MergeForm(forms.Form):
body=bleach.clean(request.POST["comment-body"], strip=True), merge_target = forms.ModelChoiceField(
) queryset=Task.objects.all(),
widget=autocomplete.ModelSelect2(
url=reverse("todo:task_autocomplete", kwargs={"task_id": task_id})
),
)
send_email_to_thread_participants( # Handle task merging
task, if not request.POST.get("merge_task_into"):
request.POST["comment-body"], merge_form = MergeForm()
request.user, else:
subject='New comment posted on task "{}"'.format(task.title), merge_form = MergeForm(request.POST)
) if merge_form.is_valid():
messages.success(request, "Comment posted. Notification email sent to thread participants.") merge_target = merge_form.cleaned_data["merge_target"]
if not user_can_read_task(merge_target, request.user):
raise PermissionDenied
task.merge_into(merge_target)
return redirect(reverse(
"todo:task_detail",
kwargs={"task_id": merge_target.pk}
))
# Save submitted comments
handle_add_comment(request, task)
# Save task edits # Save task edits
if request.POST.get("add_edit_task"): if not request.POST.get("add_edit_task"):
form = AddEditTaskForm( form = AddEditTaskForm(
request.user, request.POST, instance=task, initial={"task_list": task.task_list} request.user, instance=task, initial={"task_list": task.task_list}
)
else:
form = AddEditTaskForm(
request.user,
request.POST,
instance=task,
initial={"task_list": task.task_list},
) )
if form.is_valid(): if form.is_valid():
@ -54,10 +108,10 @@ def task_detail(request, task_id: int) -> HttpResponse:
item.save() item.save()
messages.success(request, "The task has been edited.") messages.success(request, "The task has been edited.")
return redirect( return redirect(
"todo:list_detail", list_id=task.task_list.id, list_slug=task.task_list.slug "todo:list_detail",
list_id=task.task_list.id,
list_slug=task.task_list.slug,
) )
else:
form = AddEditTaskForm(request.user, instance=task, initial={"task_list": task.task_list})
# Mark complete # Mark complete
if request.POST.get("toggle_done"): if request.POST.get("toggle_done"):
@ -72,6 +126,13 @@ def task_detail(request, task_id: int) -> HttpResponse:
else: else:
thedate = datetime.datetime.now() thedate = datetime.datetime.now()
context = {"task": task, "comment_list": comment_list, "form": form, "thedate": thedate} context = {
"task": task,
"comment_list": comment_list,
"form": form,
"merge_form": merge_form,
"thedate": thedate,
"comment_classes": getattr(settings, 'TODO_COMMENT_CLASSES', []),
}
return render(request, "todo/task_detail.html", context) return render(request, "todo/task_detail.html", context)