Merge branch 'master' into csv
This commit is contained in:
commit
9ba7e78bf3
28 changed files with 1210 additions and 315 deletions
|
@ -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:
|
||||||
|
|
7
Pipfile
7
Pipfile
|
@ -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
190
Pipfile.lock
generated
|
@ -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
243
README.md
|
@ -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.
|
|
@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
|
@ -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
19
todo/check.py
Normal 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
11
todo/features.py
Normal 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
0
todo/mail/__init__.py
Normal file
9
todo/mail/consumers/__init__.py
Normal file
9
todo/mail/consumers/__init__.py
Normal 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
|
150
todo/mail/consumers/tracker.py
Normal file
150
todo/mail/consumers/tracker.py
Normal 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
25
todo/mail/delivery.py
Normal 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')
|
9
todo/mail/producers/__init__.py
Normal file
9
todo/mail/producers/__init__.py
Normal 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
100
todo/mail/producers/imap.py
Normal 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)
|
44
todo/management/commands/mail_worker.py
Normal file
44
todo/management/commands/mail_worker.py
Normal 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())
|
46
todo/migrations/0008_mail_tracker.py
Normal file
46
todo/migrations/0008_mail_tracker.py
Normal 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")}
|
||||||
|
),
|
||||||
|
]
|
|
@ -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
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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 %}
|
||||||
|
|
||||||
|
|
|
@ -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 %}
|
||||||
|
|
|
@ -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"
|
||||||
|
|
67
todo/tests/test_tracker.py
Normal file
67
todo/tests/test_tracker.py
Normal 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>'
|
||||||
|
)
|
|
@ -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.
|
||||||
|
|
15
todo/urls.py
15
todo/urls.py
|
@ -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,
|
||||||
|
|
143
todo/utils.py
143
todo/utils.py
|
@ -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:
|
||||||
|
|
29
todo/views/task_autocomplete.py
Normal file
29
todo/views/task_autocomplete.py
Normal 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
|
|
@ -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)
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue