Compare commits
92 commits
attachment
...
master
Author | SHA1 | Date | |
---|---|---|---|
|
7df1fd995f | ||
|
9c3f7d5948 | ||
|
97ec2309f0 | ||
|
0875c84796 | ||
|
990aa1df61 | ||
|
2b43278546 | ||
|
e8284a695a | ||
|
4d2cc36338 | ||
|
e9bbe2a143 | ||
|
76170f973d | ||
ad7f63a5b9 | |||
|
7c8239b942 | ||
|
6b52a69f23 | ||
|
ef1a224546 | ||
|
33caa62205 | ||
|
834095e10a | ||
|
903a1fa4d5 | ||
|
fb59e92386 | ||
|
1b9a650a19 | ||
|
7e4935174a | ||
|
07c19e1541 | ||
|
533d5021a7 | ||
|
73c78ae875 | ||
639278299b | |||
|
3e7cbffbbd | ||
|
f80637fbfc | ||
|
8fac1e2a46 | ||
c2e5429c4d | |||
|
8be1061c52 | ||
|
bdf505b79c | ||
|
db67dde361 | ||
|
abc8d04f2f | ||
|
bcca957c21 | ||
|
f16161568e | ||
|
9ba9a4f70c | ||
|
e3d346cf6a | ||
|
6d6b6d0de0 | ||
|
6b089ea9e8 | ||
|
7e9d5f0586 | ||
75de0d5cdc | |||
|
5adc1b90ef | ||
|
d07d61f7bf | ||
|
6caa6e01af | ||
|
ae8ed85e43 | ||
|
a22215fa08 | ||
|
c294cf1694 | ||
|
bed32dcf23 | ||
|
3c882f8913 | ||
|
424bd0a051 | ||
343ea0c339 | |||
|
65b2720f70 | ||
|
aac1d41217 | ||
|
5bc450dadf | ||
6531e03b1a | |||
|
1bb80be1c9 | ||
c3d51a59bc | |||
f2d213c284 | |||
|
a6706498b6 | ||
|
5068274017 | ||
|
f13f2502a7 | ||
|
ef13cb283e | ||
|
8622493dd2 | ||
|
9f799832a9 | ||
|
92bf4fecdf | ||
|
16fa9ed24f | ||
|
c68e399eac | ||
|
c4807b7d37 | ||
|
7efa164eb8 | ||
|
2d86a51177 | ||
|
c5740b9a84 | ||
|
a5c83dad83 | ||
|
988609d265 | ||
|
58d7bdfc30 | ||
|
6c31d4446d | ||
|
ce3fd8c66e | ||
|
2b722afb8a | ||
|
d07cb30fce | ||
|
4f9f379543 | ||
|
2d40ef471e | ||
|
7f576c9bc8 | ||
|
21e0c6d656 | ||
|
caed3b384d | ||
|
befc7ad2cd | ||
|
4a385bde6b | ||
|
1cd9700366 | ||
|
7a4984dc35 | ||
|
f42d34205c | ||
|
44de86f1ca | ||
|
7fe0728716 | ||
|
edff438623 | ||
|
8a20998f8c | ||
|
602cf247e2 |
123 changed files with 839 additions and 5343 deletions
14
.gitignore
vendored
14
.gitignore
vendored
|
@ -1,14 +0,0 @@
|
|||
# tools, IDEs, build folders
|
||||
/.coverage/
|
||||
/.eggs/
|
||||
/.idea/
|
||||
/build/
|
||||
/dist/
|
||||
/docs/build/
|
||||
/*.egg-info/
|
||||
settings.json
|
||||
|
||||
# Django and Python
|
||||
*.py[cod]
|
||||
.pytest_cache/*
|
||||
.mypy_cache
|
37
.helm/templates/02-deployment.yaml
Normal file
37
.helm/templates/02-deployment.yaml
Normal file
|
@ -0,0 +1,37 @@
|
|||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ .Chart.Name }}
|
||||
namespace: {{ .Chart.Name }}-{{ .Values.global.env }}
|
||||
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: {{ .Chart.Name }}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: {{ .Chart.Name }}
|
||||
spec:
|
||||
containers:
|
||||
- name: {{ .Chart.Name }}
|
||||
image: {{ .Values.DockerImage }}
|
||||
ports:
|
||||
- containerPort: 8888
|
||||
env:
|
||||
- name: DATABASE_HOST
|
||||
value: "db-postgresql"
|
||||
- name: DATABASE_USER
|
||||
value: "postgres"
|
||||
- name: DATABASE_PASSWORD
|
||||
value: {{.Values.DBPwd | required "DBPwd is required" }}
|
||||
resources:
|
||||
limits:
|
||||
cpu: 100m
|
||||
memory: 150Mi
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 150Mi
|
||||
|
15
.helm/templates/03-service.yaml
Normal file
15
.helm/templates/03-service.yaml
Normal file
|
@ -0,0 +1,15 @@
|
|||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ .Chart.Name }}-service
|
||||
namespace: {{ .Chart.Name }}-{{ .Values.global.env }}
|
||||
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- name: http
|
||||
port: 8888
|
||||
targetPort: 8888
|
||||
selector:
|
||||
app: {{ .Chart.Name }}
|
16
.helm/templates/04-ingress.yaml
Normal file
16
.helm/templates/04-ingress.yaml
Normal file
|
@ -0,0 +1,16 @@
|
|||
apiVersion: networking.k8s.io/v1beta1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: ingress-{{ .Chart.Name }}
|
||||
namespace: {{ .Chart.Name }}-{{ .Values.global.env }}
|
||||
|
||||
annotations:
|
||||
kubernetes.io/ingress.class: "nginx"
|
||||
spec:
|
||||
rules:
|
||||
- http:
|
||||
paths:
|
||||
- path: /
|
||||
backend:
|
||||
serviceName: {{ .Chart.Name }}-service
|
||||
servicePort: 8888
|
27
.travis.yml
27
.travis.yml
|
@ -1,27 +0,0 @@
|
|||
sudo: true
|
||||
|
||||
before_install:
|
||||
- sudo apt-get update -qq
|
||||
- sudo apt-get install -qq build-essential gettext python-dev zlib1g-dev libpq-dev xvfb libjpeg8-dev
|
||||
- sudo apt-get install -qq python-setuptools python3-dev python-virtualenv python-pip
|
||||
|
||||
addons:
|
||||
postgresql: "9.6"
|
||||
|
||||
install:
|
||||
- "pip3 install pipenv"
|
||||
- "pipenv install --dev"
|
||||
- "pip3 install -e . --upgrade"
|
||||
|
||||
language: python
|
||||
python:
|
||||
- "3.6"
|
||||
|
||||
# Attempt to use cached versions of python deps
|
||||
cache: pip
|
||||
|
||||
cache:
|
||||
directories:
|
||||
- $HOME/.cache/pip
|
||||
|
||||
script: pipenv run pytest -x -v
|
15
Dockerfile
Normal file
15
Dockerfile
Normal file
|
@ -0,0 +1,15 @@
|
|||
FROM python:3.6-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add git gcc musl-dev postgresql-dev && \
|
||||
pip install pipenv && \
|
||||
git clone https://github.com/shacker/gtd.git /app && \
|
||||
pipenv --python 3.6 && \
|
||||
pipenv install --dev
|
||||
|
||||
ADD local.py project/.
|
||||
ADD entrypoint.sh .
|
||||
RUN chmod +x entrypoint.sh
|
||||
|
||||
CMD [ "./entrypoint.sh" ]
|
27
LICENSE
27
LICENSE
|
@ -1,27 +0,0 @@
|
|||
Copyright (c) 2010, Scot Hacker, Birdhouse Arts and individual contributors.
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification,
|
||||
are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice,
|
||||
this list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in the
|
||||
documentation and/or other materials provided with the distribution.
|
||||
|
||||
3. Neither the name of Birdhouse Arts nor the names of its contributors
|
||||
may be used to endorse or promote products derived from this software
|
||||
without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
|
||||
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
@ -1,4 +0,0 @@
|
|||
include LICENSE
|
||||
include README.rst
|
||||
recursive-include todo/static *
|
||||
recursive-include todo/templates *
|
23
Pipfile
23
Pipfile
|
@ -1,23 +0,0 @@
|
|||
[[source]]
|
||||
url = "https://pypi.python.org/simple"
|
||||
verify_ssl = true
|
||||
name = "pypi"
|
||||
|
||||
[packages]
|
||||
django = "*"
|
||||
django-extensions = "*"
|
||||
"psycopg2-binary" = "*"
|
||||
"flake8" = "*"
|
||||
factory-boy = "*"
|
||||
titlecase = "*"
|
||||
bleach = "*"
|
||||
django-autocomplete-light = "*"
|
||||
html2text = "*"
|
||||
|
||||
[dev-packages]
|
||||
mypy = "*"
|
||||
pytest = "*"
|
||||
pytest-django = "*"
|
||||
|
||||
[requires]
|
||||
python_version = "3.6"
|
289
Pipfile.lock
generated
289
Pipfile.lock
generated
|
@ -1,289 +0,0 @@
|
|||
{
|
||||
"_meta": {
|
||||
"hash": {
|
||||
"sha256": "c6fb601fc8a197ca280960d831a5386313c93ebe19d932afa01034d5520f2f94"
|
||||
},
|
||||
"pipfile-spec": 6,
|
||||
"requires": {
|
||||
"python_version": "3.6"
|
||||
},
|
||||
"sources": [
|
||||
{
|
||||
"name": "pypi",
|
||||
"url": "https://pypi.python.org/simple",
|
||||
"verify_ssl": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"default": {
|
||||
"bleach": {
|
||||
"hashes": [
|
||||
"sha256:213336e49e102af26d9cde77dd2d0397afabc5a6bf2fed985dc35b5d1e285a16",
|
||||
"sha256:3fdf7f77adcf649c9911387df51254b813185e32b2c6619f690b593a617e19fa"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==3.1.0"
|
||||
},
|
||||
"django": {
|
||||
"hashes": [
|
||||
"sha256:275bec66fd2588dd517ada59b8bfb23d4a9abc5a362349139ddda3c7ff6f5ade",
|
||||
"sha256:939652e9d34d7d53d74d5d8ef82a19e5f8bb2de75618f7e5360691b6e9667963"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==2.1.7"
|
||||
},
|
||||
"django-autocomplete-light": {
|
||||
"hashes": [
|
||||
"sha256:996cc62519a6e2e9cd1c26e57ddc5f14541209a93e62e83d7b3df3ba65c1f458"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==3.3.2"
|
||||
},
|
||||
"django-extensions": {
|
||||
"hashes": [
|
||||
"sha256:109004f80b6f45ad1f56addaa59debca91d94aa0dc1cb19678b9364b4fe9b6f4",
|
||||
"sha256:307766e5e6c1caffe76c5d99239d8115d14ae3f7cab2cd991fcffd763dad904b"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==2.1.6"
|
||||
},
|
||||
"entrypoints": {
|
||||
"hashes": [
|
||||
"sha256:589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19",
|
||||
"sha256:c70dd71abe5a8c85e55e12c19bd91ccfeec11a6e99044204511f9ed547d48451"
|
||||
],
|
||||
"version": "==0.3"
|
||||
},
|
||||
"factory-boy": {
|
||||
"hashes": [
|
||||
"sha256:6f25cc4761ac109efd503f096e2ad99421b1159f01a29dbb917359dcd68e08ca",
|
||||
"sha256:d552cb872b310ae78bd7429bf318e42e1e903b1a109e899a523293dfa762ea4f"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==2.11.1"
|
||||
},
|
||||
"faker": {
|
||||
"hashes": [
|
||||
"sha256:16342dca4d92bfc83bab6a7daf6650e0ab087605a66bc38f17523fdb01757910",
|
||||
"sha256:d871ea315b2dcba9138b8344f2c131a76ac62d6227ca39f69b0c889fec97376c"
|
||||
],
|
||||
"version": "==1.0.2"
|
||||
},
|
||||
"flake8": {
|
||||
"hashes": [
|
||||
"sha256:859996073f341f2670741b51ec1e67a01da142831aa1fdc6242dbf88dffbe661",
|
||||
"sha256:a796a115208f5c03b18f332f7c11729812c8c3ded6c46319c59b53efd3819da8"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==3.7.7"
|
||||
},
|
||||
"html2text": {
|
||||
"hashes": [
|
||||
"sha256:490db40fe5b2cd79c461cf56be4d39eb8ca68191ae41ba3ba79f6cb05b7dd662",
|
||||
"sha256:627514fb30e7566b37be6900df26c2c78a030cc9e6211bda604d8181233bcdd4"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==2018.1.9"
|
||||
},
|
||||
"mccabe": {
|
||||
"hashes": [
|
||||
"sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42",
|
||||
"sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"
|
||||
],
|
||||
"version": "==0.6.1"
|
||||
},
|
||||
"psycopg2-binary": {
|
||||
"hashes": [
|
||||
"sha256:19a2d1f3567b30f6c2bb3baea23f74f69d51f0c06c2e2082d0d9c28b0733a4c2",
|
||||
"sha256:2b69cf4b0fa2716fd977aa4e1fd39af6110eb47b2bb30b4e5a469d8fbecfc102",
|
||||
"sha256:2e952fa17ba48cbc2dc063ddeec37d7dc4ea0ef7db0ac1eda8906365a8543f31",
|
||||
"sha256:348b49dd737ff74cfb5e663e18cb069b44c64f77ec0523b5794efafbfa7df0b8",
|
||||
"sha256:3d72a5fdc5f00ca85160915eb9a973cf9a0ab8148f6eda40708bf672c55ac1d1",
|
||||
"sha256:4957452f7868f43f32c090dadb4188e9c74a4687323c87a882e943c2bd4780c3",
|
||||
"sha256:5138cec2ee1e53a671e11cc519505eb08aaaaf390c508f25b09605763d48de4b",
|
||||
"sha256:587098ca4fc46c95736459d171102336af12f0d415b3b865972a79c03f06259f",
|
||||
"sha256:5b79368bcdb1da4a05f931b62760bea0955ee2c81531d8e84625df2defd3f709",
|
||||
"sha256:5cf43807392247d9bc99737160da32d3fa619e0bfd85ba24d1c78db205f472a4",
|
||||
"sha256:676d1a80b1eebc0cacae8dd09b2fde24213173bf65650d22b038c5ed4039f392",
|
||||
"sha256:6b0211ecda389101a7d1d3df2eba0cf7ffbdd2480ca6f1d2257c7bd739e84110",
|
||||
"sha256:79cde4660de6f0bb523c229763bd8ad9a93ac6760b72c369cf1213955c430934",
|
||||
"sha256:7aba9786ac32c2a6d5fb446002ed936b47d5e1f10c466ef7e48f66eb9f9ebe3b",
|
||||
"sha256:7c8159352244e11bdd422226aa17651110b600d175220c451a9acf795e7414e0",
|
||||
"sha256:945f2eedf4fc6b2432697eb90bb98cc467de5147869e57405bfc31fa0b824741",
|
||||
"sha256:96b4e902cde37a7fc6ab306b3ac089a3949e6ce3d824eeca5b19dc0bedb9f6e2",
|
||||
"sha256:9a7bccb1212e63f309eb9fab47b6eaef796f59850f169a25695b248ca1bf681b",
|
||||
"sha256:a3bfcac727538ec11af304b5eccadbac952d4cca1a551a29b8fe554e3ad535dc",
|
||||
"sha256:b19e9f1b85c5d6136f5a0549abdc55dcbd63aba18b4f10d0d063eb65ef2c68b4",
|
||||
"sha256:b664011bb14ca1f2287c17185e222f2098f7b4c857961dbcf9badb28786dbbf4",
|
||||
"sha256:bde7959ef012b628868d69c474ec4920252656d0800835ed999ba5e4f57e3e2e",
|
||||
"sha256:cb095a0657d792c8de9f7c9a0452385a309dfb1bbbb3357d6b1e216353ade6ca",
|
||||
"sha256:d16d42a1b9772152c1fe606f679b2316551f7e1a1ce273e7f808e82a136cdb3d",
|
||||
"sha256:d444b1545430ffc1e7a24ce5a9be122ccd3b135a7b7e695c5862c5aff0b11159",
|
||||
"sha256:d93ccc7bf409ec0a23f2ac70977507e0b8a8d8c54e5ee46109af2f0ec9e411f3",
|
||||
"sha256:df6444f952ca849016902662e1a47abf4fa0678d75f92fd9dd27f20525f809cd",
|
||||
"sha256:e63850d8c52ba2b502662bf3c02603175c2397a9acc756090e444ce49508d41e",
|
||||
"sha256:ec43358c105794bc2b6fd34c68d27f92bea7102393c01889e93f4b6a70975728",
|
||||
"sha256:f4c6926d9c03dadce7a3b378b40d2fea912c1344ef9b29869f984fb3d2a2420b"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==2.7.7"
|
||||
},
|
||||
"pycodestyle": {
|
||||
"hashes": [
|
||||
"sha256:95a2219d12372f05704562a14ec30bc76b05a5b297b21a5dfe3f6fac3491ae56",
|
||||
"sha256:e40a936c9a450ad81df37f549d676d127b1b66000a6c500caa2b085bc0ca976c"
|
||||
],
|
||||
"version": "==2.5.0"
|
||||
},
|
||||
"pyflakes": {
|
||||
"hashes": [
|
||||
"sha256:17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0",
|
||||
"sha256:d976835886f8c5b31d47970ed689944a0262b5f3afa00a5a7b4dc81e5449f8a2"
|
||||
],
|
||||
"version": "==2.1.1"
|
||||
},
|
||||
"python-dateutil": {
|
||||
"hashes": [
|
||||
"sha256:7e6584c74aeed623791615e26efd690f29817a27c73085b78e4bad02493df2fb",
|
||||
"sha256:c89805f6f4d64db21ed966fda138f8a5ed7a4fdbc1a8ee329ce1b74e3c74da9e"
|
||||
],
|
||||
"version": "==2.8.0"
|
||||
},
|
||||
"pytz": {
|
||||
"hashes": [
|
||||
"sha256:32b0891edff07e28efe91284ed9c31e123d84bea3fd98e1f72be2508f43ef8d9",
|
||||
"sha256:d5f05e487007e29e03409f9398d074e158d920d36eb82eaf66fb1136b0c5374c"
|
||||
],
|
||||
"version": "==2018.9"
|
||||
},
|
||||
"six": {
|
||||
"hashes": [
|
||||
"sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c",
|
||||
"sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73"
|
||||
],
|
||||
"version": "==1.12.0"
|
||||
},
|
||||
"text-unidecode": {
|
||||
"hashes": [
|
||||
"sha256:5a1375bb2ba7968740508ae38d92e1f889a0832913cb1c447d5e2046061a396d",
|
||||
"sha256:801e38bd550b943563660a91de8d4b6fa5df60a542be9093f7abf819f86050cc"
|
||||
],
|
||||
"version": "==1.2"
|
||||
},
|
||||
"titlecase": {
|
||||
"hashes": [
|
||||
"sha256:84de7a97fb702c400e5ba11c6b30849944b39db12e20fbf4515a23c7538a0611",
|
||||
"sha256:95d643a0c08097c02933aced707adfe1c275c335019e8e514dea782a465c5b84"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.12.0"
|
||||
},
|
||||
"webencodings": {
|
||||
"hashes": [
|
||||
"sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78",
|
||||
"sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923"
|
||||
],
|
||||
"version": "==0.5.1"
|
||||
}
|
||||
},
|
||||
"develop": {
|
||||
"atomicwrites": {
|
||||
"hashes": [
|
||||
"sha256:03472c30eb2c5d1ba9227e4c2ca66ab8287fbfbbda3888aa93dc2e28fc6811b4",
|
||||
"sha256:75a9445bac02d8d058d5e1fe689654ba5a6556a1dfd8ce6ec55a0ed79866cfa6"
|
||||
],
|
||||
"version": "==1.3.0"
|
||||
},
|
||||
"attrs": {
|
||||
"hashes": [
|
||||
"sha256:69c0dbf2ed392de1cb5ec704444b08a5ef81680a61cb899dc08127123af36a79",
|
||||
"sha256:f0b870f674851ecbfbbbd364d6b5cbdff9dcedbc7f3f5e18a6891057f21fe399"
|
||||
],
|
||||
"version": "==19.1.0"
|
||||
},
|
||||
"more-itertools": {
|
||||
"hashes": [
|
||||
"sha256:0125e8f60e9e031347105eb1682cef932f5e97d7b9a1a28d9bf00c22a5daef40",
|
||||
"sha256:590044e3942351a1bdb1de960b739ff4ce277960f2425ad4509446dbace8d9d1"
|
||||
],
|
||||
"markers": "python_version > '2.7'",
|
||||
"version": "==6.0.0"
|
||||
},
|
||||
"mypy": {
|
||||
"hashes": [
|
||||
"sha256:308c274eb8482fbf16006f549137ddc0d69e5a589465e37b99c4564414363ca7",
|
||||
"sha256:e80fd6af34614a0e898a57f14296d0dacb584648f0339c2e000ddbf0f4cc2f8d"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.670"
|
||||
},
|
||||
"mypy-extensions": {
|
||||
"hashes": [
|
||||
"sha256:37e0e956f41369209a3d5f34580150bcacfabaa57b33a15c0b25f4b5725e0812",
|
||||
"sha256:b16cabe759f55e3409a7d231ebd2841378fb0c27a5d1994719e340e4f429ac3e"
|
||||
],
|
||||
"version": "==0.4.1"
|
||||
},
|
||||
"pluggy": {
|
||||
"hashes": [
|
||||
"sha256:19ecf9ce9db2fce065a7a0586e07cfb4ac8614fe96edf628a264b1c70116cf8f",
|
||||
"sha256:84d306a647cc805219916e62aab89caa97a33a1dd8c342e87a37f91073cd4746"
|
||||
],
|
||||
"version": "==0.9.0"
|
||||
},
|
||||
"py": {
|
||||
"hashes": [
|
||||
"sha256:64f65755aee5b381cea27766a3a147c3f15b9b6b9ac88676de66ba2ae36793fa",
|
||||
"sha256:dc639b046a6e2cff5bbe40194ad65936d6ba360b52b3c3fe1d08a82dd50b5e53"
|
||||
],
|
||||
"version": "==1.8.0"
|
||||
},
|
||||
"pytest": {
|
||||
"hashes": [
|
||||
"sha256:067a1d4bf827ffdd56ad21bd46674703fce77c5957f6c1eef731f6146bfcef1c",
|
||||
"sha256:9687049d53695ad45cf5fdc7bbd51f0c49f1ea3ecfc4b7f3fde7501b541f17f4"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==4.3.0"
|
||||
},
|
||||
"pytest-django": {
|
||||
"hashes": [
|
||||
"sha256:30d773f1768e8f214a3106f1090e00300ce6edfcac8c55fd13b675fe1cbd1c85",
|
||||
"sha256:4d3283e774fe1d40630ee58bf34929b83875e4751b525eeb07a7506996eb42ee"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==3.4.8"
|
||||
},
|
||||
"six": {
|
||||
"hashes": [
|
||||
"sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c",
|
||||
"sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73"
|
||||
],
|
||||
"version": "==1.12.0"
|
||||
},
|
||||
"typed-ast": {
|
||||
"hashes": [
|
||||
"sha256:035a54ede6ce1380599b2ce57844c6554666522e376bd111eb940fbc7c3dad23",
|
||||
"sha256:037c35f2741ce3a9ac0d55abfcd119133cbd821fffa4461397718287092d9d15",
|
||||
"sha256:049feae7e9f180b64efacbdc36b3af64a00393a47be22fa9cb6794e68d4e73d3",
|
||||
"sha256:19228f7940beafc1ba21a6e8e070e0b0bfd1457902a3a81709762b8b9039b88d",
|
||||
"sha256:2ea681e91e3550a30c2265d2916f40a5f5d89b59469a20f3bad7d07adee0f7a6",
|
||||
"sha256:3a6b0a78af298d82323660df5497bcea0f0a4a25a0b003afd0ce5af049bd1f60",
|
||||
"sha256:5385da8f3b801014504df0852bf83524599df890387a3c2b17b7caa3d78b1773",
|
||||
"sha256:606d8afa07eef77280c2bf84335e24390055b478392e1975f96286d99d0cb424",
|
||||
"sha256:69245b5b23bbf7fb242c9f8f08493e9ecd7711f063259aefffaeb90595d62287",
|
||||
"sha256:6f6d839ab09830d59b7fa8fb6917023d8cb5498ee1f1dbd82d37db78eb76bc99",
|
||||
"sha256:730888475f5ac0e37c1de4bd05eeb799fdb742697867f524dc8a4cd74bcecc23",
|
||||
"sha256:9819b5162ffc121b9e334923c685b0d0826154e41dfe70b2ede2ce29034c71d8",
|
||||
"sha256:9e60ef9426efab601dd9aa120e4ff560f4461cf8442e9c0a2b92548d52800699",
|
||||
"sha256:af5fbdde0690c7da68e841d7fc2632345d570768ea7406a9434446d7b33b0ee1",
|
||||
"sha256:b64efdbdf3bbb1377562c179f167f3bf301251411eb5ac77dec6b7d32bcda463",
|
||||
"sha256:bac5f444c118aeb456fac1b0b5d14c6a71ea2a42069b09c176f75e9bd4c186f6",
|
||||
"sha256:bda9068aafb73859491e13b99b682bd299c1b5fd50644d697533775828a28ee0",
|
||||
"sha256:d659517ca116e6750101a1326107d3479028c5191f0ecee3c7203c50f5b915b0",
|
||||
"sha256:eddd3fb1f3e0f82e5915a899285a39ee34ce18fd25d89582bc89fc9fb16cd2c6"
|
||||
],
|
||||
"version": "==1.3.1"
|
||||
}
|
||||
}
|
||||
}
|
420
README.md
420
README.md
|
@ -1,392 +1,28 @@
|
|||
# django-todo
|
||||
|
||||
django-todo is a pluggable, multi-user, multi-group task management and
|
||||
assignment application for Django, designed to be dropped into an existing site as a reusable app. django-todo can be used as a personal to-do tracker, or a group task management system, or a ticketing system for organizations (or all of these at once!)
|
||||
|
||||
**The best way to learn how django-todo works is to visit the live demo site at [django-todo.org](http://django-todo.org)!**
|
||||
|
||||
## Features
|
||||
|
||||
* Drag and drop task prioritization
|
||||
* Email task notification
|
||||
* Search
|
||||
* Comments on tasks
|
||||
* Public-facing submission form for tickets
|
||||
* Mobile-friendly (work in progress)
|
||||
* Separate view for My Tasks (across lists)
|
||||
* Batch-import tasks via CSV
|
||||
* Multiple file attachments per task (see settings)
|
||||
* Integrated mail tracking (unify a task list with an email box)
|
||||
|
||||
|
||||
## Requirements
|
||||
|
||||
* Django 2.0+
|
||||
* Python 3.6+
|
||||
* jQuery (full version, not "slim", for drag/drop prioritization)
|
||||
* Bootstrap (to work with provided templates, though you can override them)
|
||||
* bleach (`pip install bleach`)
|
||||
* django-autocomplete-light (optional, required for task merging)
|
||||
|
||||
## Overview
|
||||
|
||||
We assume that your organization has multiple groups of employees, each with multiple users (where actual users and groups map to Django Users and Groups). Users may belong to multiple groups, and each group can have multiple todo lists.
|
||||
|
||||
You must have at least one Group set up in Django admin, and that group must have at least one User as a member. This is true even if you're the sole user of django-todo.
|
||||
|
||||
Users can view and modify all to-do lists belonging to their group(s). Only users with `is_staff` can add or delete lists.
|
||||
|
||||
Identical list names can exist in different groups, but not in the same group.
|
||||
|
||||
Emails are generated to the assigned-to person when new tasks are created.
|
||||
|
||||
File attachments of a few types are allowed on tasks by default. See settings to disable or to limit filetypes. If you are concerned about file sizes, limit them in your web server configuration (not currently handled separately by django-todo).
|
||||
|
||||
Comment threads can be added to tasks. Each participant in a thread receives email when new comments are added.
|
||||
|
||||
django-todo is auth-only. You must set up a login system and at least one group before deploying.
|
||||
|
||||
All tasks are "created by" the current user and can optionally be "assigned to" a specific user. Unassigned tickets appear as belonging to "anyone" in the UI.
|
||||
|
||||
django-todo v2 makes use of features only available in Django 2.0. It will not work in previous versions. v2 is only tested against Python 3.x -- no guarantees if running it against older versions.
|
||||
|
||||
## Installation
|
||||
|
||||
django-todo is a Django app, not a project site. It needs a site to live in. You can either install it into an existing Django project site, or clone the django-todo [demo site (GTD)](https://github.com/shacker/gtd).
|
||||
|
||||
If using your own site, be sure you have jQuery and Bootstrap wired up and working.
|
||||
|
||||
django-todo views that require it will insert additional CSS/JavaScript into page heads, so your project's base templates must include:
|
||||
|
||||
```jinja
|
||||
{% block extrahead %}{% endblock extrahead %}
|
||||
{% block extra_js %}{% endblock extra_js %}
|
||||
```
|
||||
|
||||
django-todo comes with its own `todo/base.html`, which extends your master `base.html`. All content lives inside of:
|
||||
|
||||
`{% block content %}{% endblock %}`
|
||||
|
||||
If you use some other name for your main content area, you'll need to override and alter the provided templates.
|
||||
|
||||
All views are login-required. Therefore, you must have a working user authentication system.
|
||||
|
||||
For email notifications to work, make sure your site/project is [set up to send email](https://docs.djangoproject.com/en/2.0/topics/email/).
|
||||
|
||||
Make sure you've installed the Django "sites" framework and have specified the default site in settings, e.g. `SITE_ID = 1`
|
||||
|
||||
Put django-todo/todo somewhere on your Python path, or install via pip:
|
||||
|
||||
pip install django-todo
|
||||
|
||||
|
||||
Add to your settings:
|
||||
|
||||
```
|
||||
INSTALLED_APPS = (
|
||||
...
|
||||
'todo',
|
||||
)
|
||||
```
|
||||
|
||||
Migrate in database tables:
|
||||
|
||||
`python manage.py migrate todo`
|
||||
|
||||
Add to your URL conf:
|
||||
|
||||
`path('todo/', include('todo.urls', namespace="todo")),`
|
||||
|
||||
Add links to your site's navigation system:
|
||||
|
||||
```
|
||||
<a href="{% url 'todo:lists' %}">Todo Lists</a>
|
||||
<a href="{% url 'todo:mine' %}">My Tasks</a>
|
||||
```
|
||||
|
||||
django-todo makes use of the Django `messages` system. Make sure you have something like [this](https://docs.djangoproject.com/en/2.1/ref/contrib/messages/#displaying-messages) (link) in your `base.html`.
|
||||
|
||||
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.
|
||||
|
||||
### 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).
|
||||
|
||||
## Settings
|
||||
|
||||
Optional configuration params, which can be added to your project settings:
|
||||
|
||||
```python
|
||||
# Restrict access to ALL todo lists/views to `is_staff` users.
|
||||
# If False or unset, all users can see all views (but more granular permissions are still enforced
|
||||
# within views, such as requiring staff for adding and deleting lists).
|
||||
TODO_STAFF_ONLY = True
|
||||
|
||||
# If you use the "public" ticket filing option, to whom should these tickets be assigned?
|
||||
# Must be a valid username in your system. If unset, unassigned tickets go to "Anyone."
|
||||
TODO_DEFAULT_ASSIGNEE = 'johndoe'
|
||||
|
||||
# If you use the "public" ticket filing option, to which list should these tickets be saved?
|
||||
# Defaults to first list found, which is probably not what you want!
|
||||
TODO_DEFAULT_LIST_SLUG = 'tickets'
|
||||
|
||||
# If you use the "public" ticket filing option, to which *named URL* should the user be
|
||||
# redirected after submitting? (since they can't see the rest of the ticket system).
|
||||
# Defaults to "/"
|
||||
TODO_PUBLIC_SUBMIT_REDIRECT = 'dashboard'
|
||||
|
||||
# Enable or disable file attachments on Tasks
|
||||
# Optionally limit list of allowed filetypes
|
||||
TODO_ALLOW_FILE_ATTACHMENTS = True
|
||||
TODO_ALLOWED_FILE_ATTACHMENTS = [".jpg", ".gif", ".csv", ".pdf", ".zip"]
|
||||
|
||||
# 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):
|
||||
|
||||
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"}`. Follow the resulting link for the CSV web upload view.
|
||||
|
||||
|
||||
### 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
|
||||
|
||||
## Version History
|
||||
|
||||
**2.4.0** Implement optional file attachments on tasks
|
||||
|
||||
**2.3.2** Update setup.py metadata
|
||||
|
||||
**2.3.1** Improve error handling for badly formatted or non-existent CSV uploads.
|
||||
|
||||
**2.3.0** Implement mail tracking system. Added ability to batch-import tasks via CSV. Fixed task re-ordering if task deleted behind the scenes.
|
||||
|
||||
**2.2.2** Update dependencies
|
||||
|
||||
**2.2.1** Convert task delete and toggle_done views to POST only
|
||||
|
||||
**2.2.0** Re-instate enforcement of TODO_STAFF_ONLY setting
|
||||
|
||||
**2.1.1** Correct Python version requirement in documentation to Python 3.6
|
||||
|
||||
**2.1.1** Split up views into separate modules.
|
||||
|
||||
**2.1.0** December 2018: No longer allowing Javascript in task or comment bodies. Misc bug fixes.
|
||||
|
||||
**2.0.3** April 2018: Bump production status in setup.py
|
||||
|
||||
**2.0.2** April 2018: Improve notification email subjects and bodies
|
||||
|
||||
**2.0.1** April 2018: Refactored "toggle done" and "delete" actions from list view.
|
||||
|
||||
**2.0** April 2018: Major project refactor, with almost completely rewritten views, templates, and todo's first real test suite.
|
||||
|
||||
**1.6.2** Added support for unicode characters in list name/slugs.
|
||||
|
||||
**1.6.1** Minor bug fixes.
|
||||
|
||||
**1.6** Allow unassigned ("Anyone") tasks. Clean-up / modernize templates and views. Testing infrastructure in place.
|
||||
|
||||
**1.5** flake8 support, Item note no longer a required field, fix warnings for Django 1.8, Python 2/3-compatible unicode strings, simple search for tasks, get_absolute_url() for items.
|
||||
|
||||
**1.4** - Removed styling from default templates. Added excludes fields from Form definitions to prevent warnings. Removed deprecated 'cycle' tags from templates. Added settings for various elements for public ticket submissions.
|
||||
|
||||
**1.3** - Removed stray direct_to_template reference. Quoted all named URL references for Django 1.5 compatibility.
|
||||
|
||||
**1.2** - Added CSRF protection to all sample templates. Added integrated search function. Now showing the ratio of completed/total items for each
|
||||
list. Better separation of media and templates. Cleaned up Item editing form (removed extraneous fields). Re-assigning tasks now properly limits
|
||||
the list of assignees. Moved project to github.
|
||||
|
||||
**1.1** - Completion date was set properly when checking items off a list, but not when saving from an Item detail page. Added a save method on Item to
|
||||
fix. Fixed documentation bug re: context_processors. Newly added comments are now emailed to everyone who has participated in a thread on a task.
|
||||
|
||||
**1.0.1** - When viewing a single task that you want to close, it's useful to be able to comment on and close a task at the same time. We were using
|
||||
django-comments so these were different models in different views. Solution was to stop using django-comments and roll our own, then rewire the
|
||||
view. Apologies if you were using a previous version - you may need to port over your comments to the new system.
|
||||
|
||||
**1.0.0** - Major upgrade to release version. Drag and drop task prioritization. E-mail notifications (now works more like a ticket system). More
|
||||
attractive date picker. Bug fixes.
|
||||
|
||||
**0.9.5** - Fixed jquery bug when editing existing events - datepicker now shows correct date. Removed that damned Django pony from base template.
|
||||
|
||||
**0.9.4** - Replaced str with unicode in models. Fixed links back to lists in "My Tasks" view.
|
||||
|
||||
**0.9.3** - Missing link to the individual task editing view
|
||||
|
||||
**0.9.2** - Now fails gracefully when trying to add a 2nd list with the same name to the same group. - Due dates for tasks are now truly optional. -
|
||||
Corrected datetime editing conflict when editing tasks - Max length of a task name has been raised from 60 to 140 chars. If upgrading, please
|
||||
modify your database accordingly (field todo_item.name = maxlength 140). - Security: Users supplied with direct task URLs can no longer view/edit
|
||||
tasks outside their group scope Same for list views - authorized views only. - Correct item and group counts on homepage (note - admin users see
|
||||
ALL groups, not just the groups they "belong" to)
|
||||
|
||||
**0.9.1** - Removed context_processors.py - leftover turdlet
|
||||
|
||||
**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.
|
||||
# [WiP] PoC of DevOps magic
|
||||
|
||||
### Pre requires:
|
||||
1. Any x86_64 Linux distribution. Tested on Debian 10, but must work on any modern Linux system
|
||||
2. Internet access
|
||||
3. wget
|
||||
4. bash/dash shell
|
||||
5. GNU awk
|
||||
5. KVM
|
||||
|
||||
### Known bugs and limitation:
|
||||
1. Automated Keymap selection not working in the bootstrap process. You must hit Enter key.
|
||||
2. Swap automatically created in the bootstrap process, I can't switch it off now. As a result, the minimum image size must be 12Gb or more.
|
||||
3. It's still dirty, most exceptions during bootstrap still not handled
|
||||
|
||||
### For start adventures:
|
||||
1. just clone this repo
|
||||
2. go to the repo directory
|
||||
3. and type `./runme.sh` in your shell
|
||||
|
||||
### What happened after you run this script:
|
||||
1. We bootup in-userspace VM and starting the automated installation of Debian 9 with docker and common automation tools (git, ansible, helm, werf).
|
||||
2. Bootstrapping one node Kubernates cluster with Amsible (LOL we invented minicube without minicube limitations)
|
||||
3. Installing Nginx.org's Ingress with Helm
|
||||
4. Installing docker-registry in our shiny Kube (we are lazy of course we use Helm for it)
|
||||
5. Installing Postgress into Kube 0_o Also with Helm
|
||||
6. ~~Deploying test service with Helm/Werf~~
|
||||
256. ~~PROFIT!!!1~~
|
||||
|
|
15
base_urls.py
15
base_urls.py
|
@ -1,15 +0,0 @@
|
|||
from django.urls import include, path
|
||||
|
||||
"""
|
||||
This urlconf exists so we can run tests without an actual Django project
|
||||
(Django expects ROOT_URLCONF to exist.) This helps the tests remain isolated.
|
||||
For your project, ignore this file and add
|
||||
|
||||
`path('lists/', include('todo.urls')),`
|
||||
|
||||
to your site's urlconf.
|
||||
"""
|
||||
|
||||
urlpatterns = [
|
||||
path('lists/', include('todo.urls')),
|
||||
]
|
10
contrib/ansible/ansible.cfg
Normal file
10
contrib/ansible/ansible.cfg
Normal file
|
@ -0,0 +1,10 @@
|
|||
[defaults]
|
||||
allow_world_readable_tmpfiles=True
|
||||
pipelining=True
|
||||
retry_files_enabled = False
|
||||
inventory = inventory
|
||||
roles_path = roles
|
||||
library = library
|
||||
remote_tmp = /root/.ansible/tmp
|
||||
[connection]
|
||||
pipelining=True
|
6
contrib/ansible/bootstrap-node.yml
Normal file
6
contrib/ansible/bootstrap-node.yml
Normal file
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
- name: bootstrap playbook for any k8s machine
|
||||
hosts: k8s
|
||||
become: yes
|
||||
roles:
|
||||
- bootstrap
|
11
contrib/ansible/group_vars/all/main.yml
Normal file
11
contrib/ansible/group_vars/all/main.yml
Normal file
|
@ -0,0 +1,11 @@
|
|||
k8s_version: '1.17.5'
|
||||
k8s_first_master_node: 'k8s-demo'
|
||||
k8s_domain: coins.k8s.demo.ix.gs
|
||||
k8s_pod_network: '192.168.0.0/16'
|
||||
k8s_service_network: '10.254.0.0/24'
|
||||
k8s_controlplane_vip: '100.100.100.15'
|
||||
k8s_controlplane_address: '{{ k8s_controlplane_vip }}:6443'
|
||||
k8s_cluster_name: k8s-demo
|
||||
|
||||
cloud_provider: baremetal
|
||||
ha_enabled: false
|
1
contrib/ansible/host_vars/k8s-demo.yml
Normal file
1
contrib/ansible/host_vars/k8s-demo.yml
Normal file
|
@ -0,0 +1 @@
|
|||
k8s_node_role: 'master'
|
7
contrib/ansible/init-cluster.yml
Normal file
7
contrib/ansible/init-cluster.yml
Normal file
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
- name: Init k8s cluster
|
||||
hosts: 'k8s-demo'
|
||||
become: yes
|
||||
max_fail_percentage: 0
|
||||
roles:
|
||||
- init-cluster
|
2
contrib/ansible/inventory/hosts
Normal file
2
contrib/ansible/inventory/hosts
Normal file
|
@ -0,0 +1,2 @@
|
|||
[k8s]
|
||||
k8s-demo ansible_connection=local
|
6
contrib/ansible/keepalived.yml
Normal file
6
contrib/ansible/keepalived.yml
Normal file
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
- name: bootstrap playbook for any k8s machine
|
||||
hosts: k8s-masters
|
||||
become: yes
|
||||
roles:
|
||||
- keepalived
|
6
contrib/ansible/roles/bootstrap/defaults/main.yml
Normal file
6
contrib/ansible/roles/bootstrap/defaults/main.yml
Normal file
|
@ -0,0 +1,6 @@
|
|||
kubernetes_apt_release_channel: main
|
||||
# Note that xenial repo is used for all Debian derivatives at this time.
|
||||
kubernetes_apt_repository: "deb http://apt.kubernetes.io/ kubernetes-xenial {{ kubernetes_apt_release_channel }}"
|
||||
## Calico config files
|
||||
kubernetes_calico_manifest_file: https://docs.projectcalico.org/v3.10/manifests/calico.yaml
|
||||
|
6
contrib/ansible/roles/bootstrap/handlers/main.yml
Normal file
6
contrib/ansible/roles/bootstrap/handlers/main.yml
Normal file
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
- name: restart kubelet
|
||||
service: name=kubelet state=restarted
|
||||
|
||||
- name: restart docker daemon
|
||||
service: name=docker state=restarted
|
39
contrib/ansible/roles/bootstrap/tasks/main.yml
Normal file
39
contrib/ansible/roles/bootstrap/tasks/main.yml
Normal file
|
@ -0,0 +1,39 @@
|
|||
---
|
||||
- name: Ensure dependencies are installed.
|
||||
apt:
|
||||
name:
|
||||
- apt-transport-https
|
||||
- ca-certificates
|
||||
state: present
|
||||
|
||||
- name: Add Kubernetes apt key.
|
||||
apt_key:
|
||||
url: https://packages.cloud.google.com/apt/doc/apt-key.gpg
|
||||
state: present
|
||||
|
||||
- name: Add Kubernetes repository.
|
||||
apt_repository:
|
||||
repo: "{{ kubernetes_apt_repository }}"
|
||||
state: present
|
||||
update_cache: true
|
||||
|
||||
- name: Install kubeadm kubelet kubectl
|
||||
apt:
|
||||
pkg:
|
||||
- kubeadm={{ k8s_version }}-00
|
||||
- kubelet={{ k8s_version }}-00
|
||||
- kubectl={{ k8s_version }}-00
|
||||
- kubernetes-cni=0.7.5-00
|
||||
update_cache: yes
|
||||
notify: restart kubelet
|
||||
|
||||
- name: Add Kubernetes apt preferences file to pin a version.
|
||||
template:
|
||||
src: apt-preferences-kubernetes.j2
|
||||
dest: /etc/apt/preferences.d/kubernetes
|
||||
|
||||
- name:
|
||||
template:
|
||||
src: daemon.json
|
||||
dest: /etc/docker/daemon.json
|
||||
notify: restart docker daemon
|
|
@ -0,0 +1,11 @@
|
|||
Package: kubectl
|
||||
Pin: version {{ k8s_version }}.*
|
||||
Pin-Priority: 1000
|
||||
|
||||
Package: kubeadm
|
||||
Pin: version {{ k8s_version }}.*
|
||||
Pin-Priority: 1000
|
||||
|
||||
Package: kubelet
|
||||
Pin: version {{ k8s_version }}.*
|
||||
Pin-Priority: 1000
|
8
contrib/ansible/roles/bootstrap/templates/daemon.json
Normal file
8
contrib/ansible/roles/bootstrap/templates/daemon.json
Normal file
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"exec-opts": ["native.cgroupdriver=systemd"],
|
||||
"log-driver": "json-file",
|
||||
"log-opts": {
|
||||
"max-size": "100m"
|
||||
},
|
||||
"storage-driver": "overlay2"
|
||||
}
|
52
contrib/ansible/roles/init-cluster/tasks/main.yml
Normal file
52
contrib/ansible/roles/init-cluster/tasks/main.yml
Normal file
|
@ -0,0 +1,52 @@
|
|||
- name: Add Kubeadm config file
|
||||
template:
|
||||
src: kubeadm.conf.j2
|
||||
dest: /etc/kubeadm.conf
|
||||
when: k8s_node_role == 'master'
|
||||
|
||||
- name: Init cluster
|
||||
command: kubeadm init --config /etc/kubeadm.conf --upload-certs --ignore-preflight-errors serviceSubnet
|
||||
when: ansible_hostname == k8s_first_master_node
|
||||
|
||||
- name: Create kube config directory for root
|
||||
file: path=/root/.kube state=directory
|
||||
when: k8s_node_role == 'master'
|
||||
|
||||
- name: Copy Kubernetes admin config to home directory
|
||||
copy:
|
||||
src: "/etc/kubernetes/admin.conf"
|
||||
dest: "/root/.kube/config"
|
||||
# remote_src: yes
|
||||
# when: and ansible_hostname == k8s_first_master_node
|
||||
|
||||
- name: Install Calico CNI
|
||||
command: kubectl apply -f "https://docs.projectcalico.org/v3.13/manifests/calico.yaml"
|
||||
when: ansible_hostname == k8s_first_master_node
|
||||
|
||||
|
||||
- name: Generate join token
|
||||
command: kubeadm token create --print-join-command
|
||||
register: join_cmd
|
||||
delegate_to: '{{ k8s_first_master_node }}'
|
||||
|
||||
# Эта часть не работает
|
||||
# Правильная команда выглядит так:
|
||||
# kubeadm join 10.129.64.60:6443 --token <token> --discovery-token-ca-cert-hash <ca-cert-hash> --control-plane --certificate-key <key>
|
||||
# Предыдущий блок генерит сертификат и токен без указания ключа
|
||||
# Надо пофиксить как будет время
|
||||
- name: Join rest of master nodes
|
||||
command: "{{ join_cmd.stdout }} --control-plane"
|
||||
when: k8s_node_role == 'master' and ha_enabled and ansible_hostname != k8s_first_master_node
|
||||
ignore_errors: yes
|
||||
|
||||
#- name: Copy Kubernetes admin config to home directory
|
||||
# copy:
|
||||
# src: "/etc/kubernetes/admin.conf"
|
||||
# dest: "/root/.kube/config"
|
||||
# remote_src: yes
|
||||
# when: k8s_node_role == 'master'
|
||||
|
||||
- name: Join worker nodes
|
||||
command: "{{ join_cmd.stdout }}"
|
||||
when: k8s_node_role == 'worker'
|
||||
|
25
contrib/ansible/roles/init-cluster/templates/kubeadm.conf.j2
Normal file
25
contrib/ansible/roles/init-cluster/templates/kubeadm.conf.j2
Normal file
|
@ -0,0 +1,25 @@
|
|||
apiVersion: kubelet.config.k8s.io/v1beta1
|
||||
kind: KubeletConfiguration
|
||||
cgroupDriver: systemd
|
||||
---
|
||||
apiVersion: kubeadm.k8s.io/v1beta2
|
||||
kind: InitConfiguration
|
||||
nodeRegistration:
|
||||
---
|
||||
apiVersion: kubeadm.k8s.io/v1beta2
|
||||
kind: ClusterConfiguration
|
||||
kubernetesVersion: {{ k8s_version }}
|
||||
certificatesDir: /etc/kubernetes/pki
|
||||
clusterName: {{ k8s_cluster_name }}
|
||||
controlPlaneEndpoint: {{ k8s_controlplane_address }}
|
||||
dns:
|
||||
type: CoreDNS
|
||||
etcd:
|
||||
local:
|
||||
dataDir: /var/lib/etcd
|
||||
imageRepository: k8s.gcr.io
|
||||
networking:
|
||||
dnsDomain: {{ k8s_domain }}
|
||||
podSubnet: {{ k8s_pod_network }}
|
||||
serviceSubnet: {{ k8s_service_network }}
|
||||
scheduler: {}
|
1
contrib/ansible/roles/keepalived/defaults/main.yml
Normal file
1
contrib/ansible/roles/keepalived/defaults/main.yml
Normal file
|
@ -0,0 +1 @@
|
|||
---
|
3
contrib/ansible/roles/keepalived/handlers/main.yml
Normal file
3
contrib/ansible/roles/keepalived/handlers/main.yml
Normal file
|
@ -0,0 +1,3 @@
|
|||
---
|
||||
- name: restart keepalived
|
||||
service: name=keepalived state=restarted
|
14
contrib/ansible/roles/keepalived/tasks/main.yml
Normal file
14
contrib/ansible/roles/keepalived/tasks/main.yml
Normal file
|
@ -0,0 +1,14 @@
|
|||
---
|
||||
- name: Install keepalived
|
||||
apt:
|
||||
pkg:
|
||||
- keepalived
|
||||
state: latest
|
||||
|
||||
- name: Configure keepalived
|
||||
template: src=keepalived.conf.j2 dest=/etc/keepalived/keepalived.conf
|
||||
tags: keepalived
|
||||
notify: restart keepalived
|
||||
|
||||
- name: Start keepalived
|
||||
service: name=keepalived state=started
|
|
@ -0,0 +1,31 @@
|
|||
! Configuration File for keepalived
|
||||
global_defs {
|
||||
}
|
||||
|
||||
{#vrrp_script haproxy-check {
|
||||
script "killall -0 haproxy"
|
||||
interval 2
|
||||
weight 20
|
||||
}#}
|
||||
|
||||
vrrp_instance VI_1 {
|
||||
state {{ keepalived_role }}
|
||||
interface {{ keepalived_shared_iface }}
|
||||
virtual_router_id {{ keepalived_router_id }}
|
||||
{% if keepalived_role.lower() == "master" %}
|
||||
priority {{ keepalived_priority }}
|
||||
{% else %}
|
||||
priority {{ keepalived_backup_priority }}
|
||||
{% endif %}
|
||||
advert_int 1
|
||||
authentication {
|
||||
auth_type PASS
|
||||
auth_pass {{ keepalived_auth_pass }}
|
||||
}
|
||||
virtual_ipaddress {
|
||||
{{ keepalived_shared_ip }} dev {{ keepalived_shared_iface }} label {{ keepalived_shared_iface }}:0
|
||||
}
|
||||
{# track_script {
|
||||
haproxy-check weight 20
|
||||
}#}
|
||||
}
|
77
contrib/firstboot.sh
Executable file
77
contrib/firstboot.sh
Executable file
|
@ -0,0 +1,77 @@
|
|||
#!/bin/bash
|
||||
bootflag='/.manufactured'
|
||||
groot='/opt/coins-demo'
|
||||
|
||||
k8sDemoWA () {
|
||||
printf "\033c"
|
||||
echo 'Disabling swap space...'
|
||||
sync && swapoff -a && sed -i '/ swap / s/^/#/' /etc/fstab
|
||||
export KUBECONFIG=/root/.kube/config
|
||||
}
|
||||
k8sDeploy () {
|
||||
echo 'Installing K8s...'
|
||||
cd ${groot}/contrib/ansible && \
|
||||
ansible-playbook bootstrap-node.yml && \
|
||||
ansible-playbook init-cluster.yml
|
||||
# Sometimes it's still not ready on this stage, let's check it just to be sure
|
||||
while true ; do
|
||||
echo "Waiting for node up..."
|
||||
result=$(kubectl get nodes|awk '{print $2}'| tail -1| grep -nE '^Ready')
|
||||
if [ -z "$result" ] ; then
|
||||
break
|
||||
fi
|
||||
sleep 10
|
||||
done
|
||||
}
|
||||
InstallCSI () {
|
||||
helm repo add rimusz https://charts.rimusz.net
|
||||
helm install rimusz/hostpath-provisioner --generate-name
|
||||
}
|
||||
InstallRegistry () {
|
||||
helm repo add harbor https://helm.goharbor.io
|
||||
helm install registry harbor/harbor \
|
||||
--set expose.tls.enabled=false \
|
||||
--set expose.ingress.hosts.core="registry.k8s-demo.ix.gs" \
|
||||
--set expose.ingress.hosts.notary="notary.k8s-demo.ix.gs" \
|
||||
--set externalURL="http://registry.k8s-demo.ix.gs" \
|
||||
--set persistence.storageClass=hostpath \
|
||||
--set registry.credentials.username=k8s \
|
||||
--set registry.credentials.password=k8s \
|
||||
--set notary.enabled=false \
|
||||
--set trivy.enabled=false \
|
||||
--set clair.enabled=false \
|
||||
--set chartmuseum.enabled=false
|
||||
}
|
||||
InstallPGSQL () {
|
||||
helm repo add bitnami https://charts.bitnami.com/bitnami
|
||||
helm install db bitnami/postgresql \
|
||||
--set persistence.storageClass=hostpath \
|
||||
--set persistence.size=1Gi
|
||||
}
|
||||
InstallIngress () {
|
||||
# Allow scheduling on our master node
|
||||
kubectl taint nodes k8s-demo node-role.kubernetes.io/master-
|
||||
# Installing Ingress
|
||||
helm repo add nginx-stable https://helm.nginx.com/stable && \
|
||||
helm install nginx-stable/nginx-ingress --namespace kube-system --generate-name --set rbac.create=true
|
||||
# Fix external IP for LB...
|
||||
kubectl patch svc $(kubectl get svc -n kube-system|grep nginx-ingress|awk '{print $1}') -n kube-system --patch "$(cat ${groot}/contrib/ymls/ingress.fix.yaml)"
|
||||
}
|
||||
InstallApp () {
|
||||
cd ${groot}
|
||||
export WERF_INSECURE_REGISTRY=true
|
||||
export WERF_IMAGES_REPO='http://registry.k8s-demo.ix.gs/todo'
|
||||
werf build --stages-storage :local && \
|
||||
werf publish --stages-storage :local --tag-custom stable
|
||||
werf deploy --stages-storage :local --tag-custom latest --env production --set 'DBPwd=$(kubectl get secret db-postgresql -o jsonpath="{.data.postgresql-password}" | base64 --decode)'
|
||||
}
|
||||
|
||||
if [ ! -f ${bootflag} ]; then
|
||||
touch ${bootflag}
|
||||
k8sDemoWA;
|
||||
k8sDeploy;
|
||||
InstallCSI;
|
||||
InstallIngress;
|
||||
InstallRegistry;
|
||||
InstallPGSQL;
|
||||
fi
|
112
contrib/k8s-seed.txt
Normal file
112
contrib/k8s-seed.txt
Normal file
|
@ -0,0 +1,112 @@
|
|||
### Keyboard config
|
||||
d-i debian-installer/locale string en_US.UTF-8
|
||||
d-i keyboard-configuration/variant select American English
|
||||
d-i keyboard-configuration/xkb-keymap select us
|
||||
d-i keyboard-configuration/toggle select No toggling
|
||||
|
||||
### Network configuration
|
||||
d-i netcfg/choose_interface select auto
|
||||
d-i netcfg/get_hostname string unassigned-hostname
|
||||
d-i netcfg/get_domain string unassigned-domain
|
||||
d-i netcfg/hostname string k8s-demo
|
||||
d-i netcfg/wireless_wep string
|
||||
d-i netcfg/dhcp_hostname string k8s-demo
|
||||
d-i hw-detect/load_firmware boolean true
|
||||
|
||||
### Mirror settings
|
||||
d-i mirror/country string manual
|
||||
d-i mirror/http/hostname string cdn.debian.net
|
||||
d-i mirror/http/directory string /debian
|
||||
d-i mirror/http/proxy string
|
||||
|
||||
### Account setup
|
||||
d-i passwd/root-login boolean false
|
||||
|
||||
# To create a normal user account.
|
||||
d-i passwd/user-fullname string K8S Admin
|
||||
d-i passwd/username string k8s
|
||||
d-i passwd/user-password password K8Sdemo
|
||||
d-i passwd/user-password-again password K8Sdemo
|
||||
|
||||
# The user account will be added to some standard initial groups. To
|
||||
# override that, use this.
|
||||
#d-i passwd/user-default-groups string docker
|
||||
|
||||
### Clock and time zone setup
|
||||
d-i clock-setup/utc boolean true
|
||||
d-i time/zone string UTC
|
||||
d-i clock-setup/ntp boolean true
|
||||
d-i clock-setup/ntp-server string clock.ix.gs
|
||||
|
||||
### Partitioning
|
||||
d-i partman-auto/method string regular
|
||||
d-i partman-lvm/device_remove_lvm boolean true
|
||||
d-i partman-md/device_remove_md boolean true
|
||||
d-i partman-lvm/confirm boolean true
|
||||
d-i partman-lvm/confirm_nooverwrite boolean true
|
||||
d-i partman-auto/choose_recipe select atomic
|
||||
d-i partman-basicfilesystems/no_swap boolean true
|
||||
d-i partman-partitioning/confirm_write_new_label boolean true
|
||||
d-i partman/choose_partition select finish
|
||||
d-i partman/confirm boolean true
|
||||
d-i partman/confirm_nooverwrite boolean true
|
||||
d-i partman/default_filesystem string xfs
|
||||
|
||||
### Apt setup
|
||||
d-i apt-setup/non-free boolean true
|
||||
d-i apt-setup/contrib boolean true
|
||||
d-i debian-installer/allow_unauthenticated boolean true
|
||||
|
||||
|
||||
### Package selection
|
||||
tasksel tasksel/first multiselect none, ssh-server, standard
|
||||
|
||||
# Individual additional packages to install
|
||||
d-i pkgsel/include string \
|
||||
apt-transport-https gnupg2 ca-certificates curl \
|
||||
iptables linux-headers-amd64 git ansible wget \
|
||||
gdebi-core
|
||||
|
||||
# Whether to upgrade packages after debootstrap.
|
||||
# Allowed values: none, safe-upgrade, full-upgrade
|
||||
d-i pkgsel/upgrade select full-upgrade
|
||||
popularity-contest popularity-contest/participate boolean true
|
||||
|
||||
### Boot loader installation
|
||||
d-i grub-installer/only_debian boolean true
|
||||
d-i grub-installer/with_other_os boolean true
|
||||
d-i grub-installer/bootdev string default
|
||||
|
||||
### Finishing up the installation
|
||||
d-i finish-install/reboot_in_progress note
|
||||
d-i debian-installer/exit/poweroff boolean true
|
||||
|
||||
#### Advanced options
|
||||
### Running custom commands during the installation
|
||||
d-i preseed/late_command string in-target /bin/sh -c " \
|
||||
echo k8s-demo > /etc/hostname ; \
|
||||
sed -i 's/debian/k8s-demo/' /etc/hosts ; \
|
||||
update-alternatives --set iptables /usr/sbin/iptables-legacy ; \
|
||||
update-alternatives --set ip6tables /usr/sbin/ip6tables-legacy ; \
|
||||
update-alternatives --set arptables /usr/sbin/arptables-legacy ; \
|
||||
update-alternatives --set ebtables /usr/sbin/ebtables-legacy ; \
|
||||
( systemctl disable nftables && systemctl mask nftables ) ; \
|
||||
curl -s https://download.docker.com/linux/debian/gpg | sudo apt-key add - ; \
|
||||
curl https://helm.baltorepo.com/organization/signing.asc | sudo apt-key add - ; \
|
||||
echo 'deb [arch=amd64] https://download.docker.com/linux/debian/ stretch stable' >/etc/apt/sources.list.d/docker.list ; \
|
||||
echo 'deb https://baltocdn.com/helm/stable/debian/ all main' > /etc/apt/sources.list.d/helm-stable-debian.list ; \
|
||||
apt-get update ; \
|
||||
apt-get install -y \
|
||||
docker-ce docker-ce-cli containerd.io helm ; \
|
||||
apt-get clean ; \
|
||||
curl -L https://dl.bintray.com/flant/werf/v1.1.19+fix10/werf-linux-amd64-v1.1.19+fix10 -o /usr/bin/werf ; \
|
||||
chmod +x /usr/bin/werf ; \
|
||||
git clone https://git.ix.gs/public/coins-demo.git /opt/coins-demo ; \
|
||||
cp /opt/coins-demo/contrib/mfg.service /etc/systemd/system/ ; \
|
||||
mkdir -p /lib/systemd/system/docker.service.d ; \
|
||||
sed -i 's/sock$/sock --insecure-registry="registry.k8s-demo.ix.gs"/' /lib/systemd/system/docker.service ; \
|
||||
systemctl daemon-reload ; \
|
||||
systemctl enable mfg.service ; \
|
||||
systemctl enable serial-getty@ttyS0.service ; \
|
||||
systemctl enable docker.service \
|
||||
|| true"
|
18
contrib/mfg.service
Normal file
18
contrib/mfg.service
Normal file
|
@ -0,0 +1,18 @@
|
|||
[Unit]
|
||||
Description=Manufacturing service
|
||||
After=serial-getty@ttyS0.service
|
||||
|
||||
[Service]
|
||||
Type=idle
|
||||
ExecStart=/opt/coins-demo/contrib/firstboot.sh
|
||||
StandardInput=tty
|
||||
StandardOutput=tty
|
||||
TTYPath=/dev/ttyS0
|
||||
TTYReset=yes
|
||||
#TTYVHangup=yes
|
||||
KillMode=none
|
||||
TimeoutSec=0
|
||||
RemainAfterExit=yes
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
BIN
contrib/qemu-system-x86_64
Executable file
BIN
contrib/qemu-system-x86_64
Executable file
Binary file not shown.
BIN
contrib/share/qemu/bios-256k.bin
Normal file
BIN
contrib/share/qemu/bios-256k.bin
Normal file
Binary file not shown.
BIN
contrib/share/qemu/efi-e1000.rom
Normal file
BIN
contrib/share/qemu/efi-e1000.rom
Normal file
Binary file not shown.
157
contrib/share/qemu/keymaps/common
Normal file
157
contrib/share/qemu/keymaps/common
Normal file
|
@ -0,0 +1,157 @@
|
|||
include modifiers
|
||||
|
||||
#
|
||||
# Top row
|
||||
#
|
||||
1 0x2
|
||||
2 0x3
|
||||
3 0x4
|
||||
4 0x5
|
||||
5 0x6
|
||||
6 0x7
|
||||
7 0x8
|
||||
8 0x9
|
||||
9 0xa
|
||||
0 0xb
|
||||
BackSpace 0xe
|
||||
|
||||
#
|
||||
# QWERTY first row
|
||||
#
|
||||
Tab 0xf localstate
|
||||
ISO_Left_Tab 0xf shift
|
||||
q 0x10 addupper
|
||||
w 0x11 addupper
|
||||
e 0x12 addupper
|
||||
r 0x13 addupper
|
||||
t 0x14 addupper
|
||||
y 0x15 addupper
|
||||
u 0x16 addupper
|
||||
i 0x17 addupper
|
||||
o 0x18 addupper
|
||||
p 0x19 addupper
|
||||
|
||||
#
|
||||
# QWERTY second row
|
||||
#
|
||||
a 0x1e addupper
|
||||
s 0x1f addupper
|
||||
d 0x20 addupper
|
||||
f 0x21 addupper
|
||||
g 0x22 addupper
|
||||
h 0x23 addupper
|
||||
j 0x24 addupper
|
||||
k 0x25 addupper
|
||||
l 0x26 addupper
|
||||
Return 0x1c localstate
|
||||
|
||||
#
|
||||
# QWERTY third row
|
||||
#
|
||||
z 0x2c addupper
|
||||
x 0x2d addupper
|
||||
c 0x2e addupper
|
||||
v 0x2f addupper
|
||||
b 0x30 addupper
|
||||
n 0x31 addupper
|
||||
m 0x32 addupper
|
||||
|
||||
space 0x39 localstate
|
||||
|
||||
less 0x56
|
||||
greater 0x56 shift
|
||||
bar 0x56 altgr
|
||||
brokenbar 0x56 shift altgr
|
||||
|
||||
#
|
||||
# Esc and Function keys
|
||||
#
|
||||
Escape 0x1 localstate
|
||||
F1 0x3b localstate
|
||||
F2 0x3c localstate
|
||||
F3 0x3d localstate
|
||||
F4 0x3e localstate
|
||||
F5 0x3f localstate
|
||||
F6 0x40 localstate
|
||||
F7 0x41 localstate
|
||||
F8 0x42 localstate
|
||||
F9 0x43 localstate
|
||||
F10 0x44 localstate
|
||||
F11 0x57 localstate
|
||||
F12 0x58 localstate
|
||||
|
||||
# Printscreen, Scrollock and Pause
|
||||
# Printscreen really requires four scancodes (0xe0, 0x2a, 0xe0, 0x37),
|
||||
# but (0xe0, 0x37) seems to work.
|
||||
Print 0xb7 localstate
|
||||
Sys_Req 0xb7 localstate
|
||||
Execute 0xb7 localstate
|
||||
Scroll_Lock 0x46
|
||||
|
||||
#
|
||||
# Insert - PgDown
|
||||
#
|
||||
Insert 0xd2 localstate
|
||||
Delete 0xd3 localstate
|
||||
Home 0xc7 localstate
|
||||
End 0xcf localstate
|
||||
Page_Up 0xc9 localstate
|
||||
Page_Down 0xd1 localstate
|
||||
|
||||
#
|
||||
# Arrow keys
|
||||
#
|
||||
Left 0xcb localstate
|
||||
Up 0xc8 localstate
|
||||
Down 0xd0 localstate
|
||||
Right 0xcd localstate
|
||||
|
||||
#
|
||||
# Numpad
|
||||
#
|
||||
Num_Lock 0x45
|
||||
KP_Divide 0xb5
|
||||
KP_Multiply 0x37
|
||||
KP_Subtract 0x4a
|
||||
KP_Add 0x4e
|
||||
KP_Enter 0x9c
|
||||
|
||||
KP_Decimal 0x53 numlock
|
||||
KP_Separator 0x53 numlock
|
||||
KP_Delete 0x53
|
||||
|
||||
KP_0 0x52 numlock
|
||||
KP_Insert 0x52
|
||||
|
||||
KP_1 0x4f numlock
|
||||
KP_End 0x4f
|
||||
|
||||
KP_2 0x50 numlock
|
||||
KP_Down 0x50
|
||||
|
||||
KP_3 0x51 numlock
|
||||
KP_Next 0x51
|
||||
|
||||
KP_4 0x4b numlock
|
||||
KP_Left 0x4b
|
||||
|
||||
KP_5 0x4c numlock
|
||||
KP_Begin 0x4c
|
||||
|
||||
KP_6 0x4d numlock
|
||||
KP_Right 0x4d
|
||||
|
||||
KP_7 0x47 numlock
|
||||
KP_Home 0x47
|
||||
|
||||
KP_8 0x48 numlock
|
||||
KP_Up 0x48
|
||||
|
||||
KP_9 0x49 numlock
|
||||
KP_Prior 0x49
|
||||
|
||||
Caps_Lock 0x3a
|
||||
#
|
||||
# Inhibited keys
|
||||
#
|
||||
Multi_key 0x0 inhibit
|
35
contrib/share/qemu/keymaps/en-us
Normal file
35
contrib/share/qemu/keymaps/en-us
Normal file
|
@ -0,0 +1,35 @@
|
|||
# generated from XKB map us
|
||||
include common
|
||||
map 0x409
|
||||
exclam 0x02 shift
|
||||
at 0x03 shift
|
||||
numbersign 0x04 shift
|
||||
dollar 0x05 shift
|
||||
percent 0x06 shift
|
||||
asciicircum 0x07 shift
|
||||
ampersand 0x08 shift
|
||||
asterisk 0x09 shift
|
||||
parenleft 0x0a shift
|
||||
parenright 0x0b shift
|
||||
minus 0x0c
|
||||
underscore 0x0c shift
|
||||
equal 0x0d
|
||||
plus 0x0d shift
|
||||
bracketleft 0x1a
|
||||
braceleft 0x1a shift
|
||||
bracketright 0x1b
|
||||
braceright 0x1b shift
|
||||
semicolon 0x27
|
||||
colon 0x27 shift
|
||||
apostrophe 0x28
|
||||
quotedbl 0x28 shift
|
||||
grave 0x29
|
||||
asciitilde 0x29 shift
|
||||
backslash 0x2b
|
||||
bar 0x2b shift
|
||||
comma 0x33
|
||||
less 0x33 shift
|
||||
period 0x34
|
||||
greater 0x34 shift
|
||||
slash 0x35
|
||||
question 0x35 shift
|
18
contrib/share/qemu/keymaps/modifiers
Normal file
18
contrib/share/qemu/keymaps/modifiers
Normal file
|
@ -0,0 +1,18 @@
|
|||
Shift_R 0x36
|
||||
Shift_L 0x2a
|
||||
|
||||
Alt_R 0xb8
|
||||
Mode_switch 0xb8
|
||||
ISO_Level3_Shift 0xb8
|
||||
Alt_L 0x38
|
||||
|
||||
Control_R 0x9d
|
||||
Control_L 0x1d
|
||||
|
||||
# Translate Super to Windows keys.
|
||||
# This is hardcoded. See documentation for details.
|
||||
Super_R 0xdc
|
||||
Super_L 0xdb
|
||||
|
||||
# Translate Menu to the Windows Application key.
|
||||
Menu 0xdd
|
BIN
contrib/share/qemu/kvmvapic.bin
Normal file
BIN
contrib/share/qemu/kvmvapic.bin
Normal file
Binary file not shown.
BIN
contrib/share/qemu/linuxboot.bin
Normal file
BIN
contrib/share/qemu/linuxboot.bin
Normal file
Binary file not shown.
BIN
contrib/share/qemu/vgabios-stdvga.bin
Normal file
BIN
contrib/share/qemu/vgabios-stdvga.bin
Normal file
Binary file not shown.
4
contrib/ymls/ingress.fix.yaml
Normal file
4
contrib/ymls/ingress.fix.yaml
Normal file
|
@ -0,0 +1,4 @@
|
|||
spec:
|
||||
externalIPs:
|
||||
- 100.100.100.15
|
||||
loadBalancerIP: 100.100.100.15
|
399
docs/index.md
399
docs/index.md
|
@ -1,399 +0,0 @@
|
|||
# django-todo
|
||||
|
||||
django-todo is a pluggable, multi-user, multi-group task management and
|
||||
assignment application for Django, designed to be dropped into an existing site as a reusable app. django-todo can be used as a personal to-do tracker, or a group task management system, or a ticketing system for organizations (or all of these at once!)
|
||||
|
||||
**The best way to learn how django-todo works is to visit the live demo site at [django-todo.org](http://django-todo.org)!**
|
||||
|
||||
## Features
|
||||
|
||||
* Drag and drop task prioritization
|
||||
* Email task notification
|
||||
* Search
|
||||
* Comments on tasks
|
||||
* Public-facing submission form for tickets
|
||||
* Mobile-friendly (work in progress)
|
||||
* Separate view for My Tasks (across lists)
|
||||
* Batch-import tasks via CSV
|
||||
* Integrated mail tracking (unify a task list with an email box)
|
||||
|
||||
|
||||
## Requirements
|
||||
|
||||
* Django 2.0+
|
||||
* Python 3.6+
|
||||
* jQuery (full version, not "slim", for drag/drop prioritization)
|
||||
* Bootstrap (to work with provided templates, though you can override them)
|
||||
* bleach (`pip install bleach`)
|
||||
* django-autocomplete-light (optional, required for task merging)
|
||||
|
||||
## Overview
|
||||
|
||||
We assume that your organization has multiple groups of employees, each with multiple users (where actual users and groups map to Django Users and Groups). Users may belong to multiple groups, and each group can have multiple todo lists.
|
||||
|
||||
You must have at least one Group set up in Django admin, and that group must have at least one User as a member. This is true even if you're the sole user of django-todo.
|
||||
|
||||
Users can view and modify all to-do lists belonging to their group(s). Only users with `is_staff` can add or delete lists.
|
||||
|
||||
Identical list names can exist in different groups, but not in the same group.
|
||||
|
||||
Emails are generated to the assigned-to person when new tasks are created.
|
||||
|
||||
Comment threads can be added to tasks. Each participant in a thread receives email when new comments are added.
|
||||
|
||||
django-todo is auth-only. You must set up a login system and at least one group before deploying.
|
||||
|
||||
All tasks are "created by" the current user and can optionally be "assigned to" a specific user. Unassigned tickets appear as belonging to "anyone" in the UI.
|
||||
|
||||
django-todo v2 makes use of features only available in Django 2.0. It will not work in previous versions. v2 is only tested against Python 3.x -- no guarantees if running it against older versions.
|
||||
|
||||
## Installation
|
||||
|
||||
django-todo is a Django app, not a project site. It needs a site to live in. You can either install it into an existing Django project site, or clone the django-todo [demo site (GTD)](https://github.com/shacker/gtd).
|
||||
|
||||
If using your own site, be sure you have jQuery and Bootstrap wired up and working.
|
||||
|
||||
django-todo views that require it will insert additional CSS/JavaScript into page heads, so your project's base templates must include:
|
||||
|
||||
```jinja
|
||||
{% block extrahead %}{% endblock extrahead %}
|
||||
{% block extra_js %}{% endblock extra_js %}
|
||||
```
|
||||
|
||||
django-todo comes with its own `todo/base.html`, which extends your master `base.html`. All content lives inside of:
|
||||
|
||||
`{% block content %}{% endblock %}`
|
||||
|
||||
If you use some other name for your main content area, you'll need to override and alter the provided templates.
|
||||
|
||||
All views are login-required. Therefore, you must have a working user authentication system.
|
||||
|
||||
For email notifications to work, make sure your site/project is [set up to send email](https://docs.djangoproject.com/en/2.0/topics/email/).
|
||||
|
||||
Make sure you've installed the Django "sites" framework and have specified the default site in settings, e.g. `SITE_ID = 1`
|
||||
|
||||
Put django-todo/todo somewhere on your Python path, or install via pip:
|
||||
|
||||
pip install django-todo
|
||||
|
||||
|
||||
Add to your settings:
|
||||
|
||||
```
|
||||
INSTALLED_APPS = (
|
||||
...
|
||||
'todo',
|
||||
)
|
||||
```
|
||||
|
||||
Migrate in database tables:
|
||||
|
||||
`python manage.py migrate todo`
|
||||
|
||||
Add to your URL conf:
|
||||
|
||||
`path('todo/', include('todo.urls', namespace="todo")),`
|
||||
|
||||
Add links to your site's navigation system:
|
||||
|
||||
```
|
||||
<a href="{% url 'todo:lists' %}">Todo Lists</a>
|
||||
<a href="{% url 'todo:mine' %}">My Tasks</a>
|
||||
```
|
||||
|
||||
django-todo makes use of the Django `messages` system. Make sure you have something like [this](https://docs.djangoproject.com/en/2.0/ref/contrib/messages/#displaying-messages) (link) in your `base.html`.
|
||||
|
||||
Log in and access `/todo`!
|
||||
|
||||
### 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.
|
||||
|
||||
### 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).
|
||||
|
||||
## Settings
|
||||
|
||||
Optional configuration params, which can be added to your project settings:
|
||||
|
||||
```python
|
||||
# Restrict access to ALL todo lists/views to `is_staff` users.
|
||||
# If False or unset, all users can see all views (but more granular permissions are still enforced
|
||||
# within views, such as requiring staff for adding and deleting lists).
|
||||
TODO_STAFF_ONLY = True
|
||||
|
||||
# If you use the "public" ticket filing option, to whom should these tickets be assigned?
|
||||
# Must be a valid username in your system. If unset, unassigned tickets go to "Anyone."
|
||||
TODO_DEFAULT_ASSIGNEE = 'johndoe'
|
||||
|
||||
# If you use the "public" ticket filing option, to which list should these tickets be saved?
|
||||
# Defaults to first list found, which is probably not what you want!
|
||||
TODO_DEFAULT_LIST_SLUG = 'tickets'
|
||||
|
||||
# If you use the "public" ticket filing option, to which *named URL* should the user be
|
||||
# redirected after submitting? (since they can't see the rest of the ticket system).
|
||||
# Defaults to "/"
|
||||
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):
|
||||
|
||||
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"}`. Follow the resulting link for the CSV web upload view.
|
||||
|
||||
|
||||
### 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
|
||||
|
||||
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.
|
||||
|
||||
## Version History
|
||||
|
||||
**2.3.0** Implement mail tracking system. Added ability to batch-import tasks via CSV. Fixed task re-ordering if task deleted behind the scenes.
|
||||
|
||||
**2.2.2** Update dependencies
|
||||
|
||||
**2.2.1** Convert task delete and toggle_done views to POST only
|
||||
|
||||
**2.2.0** Re-instate enforcement of TODO_STAFF_ONLY setting
|
||||
|
||||
**2.1.1** Correct Python version requirement in documentation to Python 3.6
|
||||
|
||||
**2.1.1** Split up views into separate modules.
|
||||
|
||||
**2.1.0** December 2018: No longer allowing Javascript in task or comment bodies. Misc bug fixes.
|
||||
|
||||
**2.0.3** April 2018: Bump production status in setup.py
|
||||
|
||||
**2.0.2** April 2018: Improve notification email subjects and bodies
|
||||
|
||||
**2.0.1** April 2018: Refactored "toggle done" and "delete" actions from list view.
|
||||
|
||||
**2.0** April 2018: Major project refactor, with almost completely rewritten views, templates, and todo's first real test suite.
|
||||
|
||||
**1.6.2** Added support for unicode characters in list name/slugs.
|
||||
|
||||
**1.6.1** Minor bug fixes.
|
||||
|
||||
**1.6** Allow unassigned ("Anyone") tasks. Clean-up / modernize templates and views. Testing infrastructure in place.
|
||||
|
||||
**1.5** flake8 support, Item note no longer a required field, fix warnings for Django 1.8, Python 2/3-compatible unicode strings, simple search for tasks, get_absolute_url() for items.
|
||||
|
||||
**1.4** - Removed styling from default templates. Added excludes fields from Form definitions to prevent warnings. Removed deprecated 'cycle' tags from templates. Added settings for various elements for public ticket submissions.
|
||||
|
||||
**1.3** - Removed stray direct_to_template reference. Quoted all named URL references for Django 1.5 compatibility.
|
||||
|
||||
**1.2** - Added CSRF protection to all sample templates. Added integrated search function. Now showing the ratio of completed/total items for each
|
||||
list. Better separation of media and templates. Cleaned up Item editing form (removed extraneous fields). Re-assigning tasks now properly limits
|
||||
the list of assignees. Moved project to github.
|
||||
|
||||
**1.1** - Completion date was set properly when checking items off a list, but not when saving from an Item detail page. Added a save method on Item to
|
||||
fix. Fixed documentation bug re: context_processors. Newly added comments are now emailed to everyone who has participated in a thread on a task.
|
||||
|
||||
**1.0.1** - When viewing a single task that you want to close, it's useful to be able to comment on and close a task at the same time. We were using
|
||||
django-comments so these were different models in different views. Solution was to stop using django-comments and roll our own, then rewire the
|
||||
view. Apologies if you were using a previous version - you may need to port over your comments to the new system.
|
||||
|
||||
**1.0.0** - Major upgrade to release version. Drag and drop task prioritization. E-mail notifications (now works more like a ticket system). More
|
||||
attractive date picker. Bug fixes.
|
||||
|
||||
**0.9.5** - Fixed jquery bug when editing existing events - datepicker now shows correct date. Removed that damned Django pony from base template.
|
||||
|
||||
**0.9.4** - Replaced str with unicode in models. Fixed links back to lists in "My Tasks" view.
|
||||
|
||||
**0.9.3** - Missing link to the individual task editing view
|
||||
|
||||
**0.9.2** - Now fails gracefully when trying to add a 2nd list with the same name to the same group. - Due dates for tasks are now truly optional. -
|
||||
Corrected datetime editing conflict when editing tasks - Max length of a task name has been raised from 60 to 140 chars. If upgrading, please
|
||||
modify your database accordingly (field todo_item.name = maxlength 140). - Security: Users supplied with direct task URLs can no longer view/edit
|
||||
tasks outside their group scope Same for list views - authorized views only. - Correct item and group counts on homepage (note - admin users see
|
||||
ALL groups, not just the groups they "belong" to)
|
||||
|
||||
**0.9.1** - Removed context_processors.py - leftover turdlet
|
||||
|
||||
**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.
|
3
entrypoint.sh
Normal file
3
entrypoint.sh
Normal file
|
@ -0,0 +1,3 @@
|
|||
#!/bin/sh
|
||||
pipenv run python manage.py migrate todo
|
||||
pipenv run python manage.py runserver 0.0.0.0:8888
|
26
local.py
Normal file
26
local.py
Normal file
|
@ -0,0 +1,26 @@
|
|||
from .settings import *
|
||||
import os
|
||||
|
||||
DEBUG = True
|
||||
|
||||
ALLOWED_HOSTS = ['*']
|
||||
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.postgresql',
|
||||
'NAME': os.environ['DATABASE_NAME'],
|
||||
'HOST': os.environ['DATABASE_HOST'],
|
||||
'USER': os.environ['DATABASE_USER'],
|
||||
'PASSWORD': os.environ['DATABASE_PASSWORD'],
|
||||
'PORT': '',
|
||||
},
|
||||
}
|
||||
|
||||
SECRET_KEY = os.environ['SECRET_KEY']
|
||||
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
|
||||
|
||||
# TODO-specific settings
|
||||
TODO_STAFF_ONLY = False
|
||||
TODO_DEFAULT_LIST_SLUG = 'tickets'
|
||||
TODO_DEFAULT_ASSIGNEE = None
|
||||
TODO_PUBLIC_SUBMIT_REDIRECT = '/'
|
|
@ -1,2 +0,0 @@
|
|||
site_name: "readthedocs"
|
||||
theme: "readthedocs"
|
|
@ -1,4 +0,0 @@
|
|||
[pytest]
|
||||
DJANGO_SETTINGS_MODULE = test_settings
|
||||
# -- recommended but optional:
|
||||
python_files = tests.py test_*.py *_tests.py
|
34
runme.sh
Executable file
34
runme.sh
Executable file
|
@ -0,0 +1,34 @@
|
|||
#!/bin/bash
|
||||
appdir=$(cd `dirname $0` && pwd)
|
||||
apptmp='/tmp'
|
||||
vmsize='16G'
|
||||
vmmem='4G'
|
||||
vcpu=`cat /proc/cpuinfo|grep proc|tail -1|awk '{print $NF}'`
|
||||
k8sdsk="${apptmp}/k8s-demo.raw"
|
||||
httpboot='http://ftp.debian.org/debian/dists/stretch/main/installer-amd64/current/images/netboot/debian-installer/amd64'
|
||||
seed='https://git.ix.gs/public/coins-demo/raw/branch/master/contrib/k8s-seed.txt'
|
||||
qemu="${appdir}/contrib/qemu-system-x86_64 -L ${appdir}/contrib/share/qemu -net nic -net user,net=100.100.100.0/24,hostfwd=tcp::8888-:80 -m ${vmmem} -smp ${vcpu} -localtime -enable-kvm -cpu host,+nx -M pc -vga std -usbdevice tablet -k en-us -hda ${k8sdsk} -boot once=d -nographic"
|
||||
wget='wget -cq4O'
|
||||
###
|
||||
installvm() {
|
||||
echo "Allocating ${vmsize} disk..." && \
|
||||
fallocate -l ${vmsize} ${k8sdsk} && \
|
||||
echo "Downloading Linux kernel..." && \
|
||||
${wget} ${apptmp}/kernel ${httpboot}/linux && \
|
||||
echo "Downloading Initial ramdisk..." && \
|
||||
${wget} ${apptmp}/initrd ${httpboot}/initrd.gz && \
|
||||
echo "Preparing K8S VM..." && \
|
||||
${qemu} -kernel ${apptmp}/kernel -initrd ${apptmp}/initrd -append "console=ttyS0,115200n8 apt-setup/proposed=true nomodeset fb=false priority=critical locale=en_US url=${seed}" && \
|
||||
echo "Housekeeping..." && \
|
||||
rm -f ${apptmp}/kernel ${apptmp}/initrd
|
||||
}
|
||||
runvm() {
|
||||
echo "Look like we already has VM, let's go"
|
||||
${qemu}
|
||||
}
|
||||
###
|
||||
if [ ! -f "${k8sdsk}" ]; then
|
||||
installvm && runvm;
|
||||
else
|
||||
runvm;
|
||||
fi
|
46
setup.py
46
setup.py
|
@ -1,46 +0,0 @@
|
|||
# Based on setup.py master example at https://github.com/pypa/sampleproject/blob/master/setup.py
|
||||
|
||||
from io import open
|
||||
from os import path
|
||||
|
||||
from setuptools import find_packages, setup
|
||||
|
||||
here = path.abspath(path.dirname(__file__))
|
||||
|
||||
# Get the long description from the README file
|
||||
with open(path.join(here, "README.md"), encoding="utf-8") as f:
|
||||
long_description = f.read()
|
||||
|
||||
setup(
|
||||
name="django-todo",
|
||||
version="2.4.0",
|
||||
description="A multi-user, multi-group task management and assignment system for Django.",
|
||||
long_description=long_description,
|
||||
long_description_content_type="text/markdown",
|
||||
url="https://github.com/shacker/django-todo",
|
||||
author="Scot Hacker",
|
||||
# For a list of valid classifiers, see https://pypi.org/classifiers/
|
||||
classifiers=[
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: BSD License",
|
||||
"Programming Language :: Python :: 3.6",
|
||||
"Programming Language :: Python :: 3.7",
|
||||
"Environment :: Web Environment",
|
||||
"Framework :: Django",
|
||||
"Operating System :: OS Independent",
|
||||
"Topic :: Office/Business :: Groupware",
|
||||
"Topic :: Office/Business :: Groupware",
|
||||
"Topic :: Software Development :: Bug Tracking",
|
||||
"Topic :: Software Development :: Bug Tracking",
|
||||
],
|
||||
keywords="lists todo bug bugs tracking",
|
||||
packages=find_packages(exclude=["contrib", "docs", "tests"]),
|
||||
python_requires=">=3.5",
|
||||
install_requires=["unidecode"],
|
||||
project_urls={
|
||||
"Demo Site": "http://django-todo.org",
|
||||
"Bug Reports": "https://github.com/shacker/django-todo/issues",
|
||||
"Source": "https://github.com/shacker/django-todo",
|
||||
},
|
||||
)
|
|
@ -1,92 +0,0 @@
|
|||
import os
|
||||
|
||||
DEBUG = (True,)
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(__file__))
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3"
|
||||
}
|
||||
}
|
||||
|
||||
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
|
||||
|
||||
# Document
|
||||
TODO_STAFF_ONLY = False
|
||||
TODO_DEFAULT_LIST_SLUG = "tickets"
|
||||
TODO_DEFAULT_ASSIGNEE = None
|
||||
TODO_PUBLIC_SUBMIT_REDIRECT = "/"
|
||||
|
||||
SECRET_KEY = "LKFSD8sdl.,8&sdf--"
|
||||
|
||||
SITE_ID = 1
|
||||
|
||||
INSTALLED_APPS = (
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.sites",
|
||||
"django.contrib.staticfiles",
|
||||
"todo",
|
||||
"dal",
|
||||
"dal_select2",
|
||||
)
|
||||
|
||||
ROOT_URLCONF = "base_urls"
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
]
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [os.path.join(BASE_DIR, "todo", "templates")],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.debug",
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.template.context_processors.media",
|
||||
"django.template.context_processors.static",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
# Your stuff: custom template context processors go here
|
||||
]
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
LOGGING = {
|
||||
'version': 1,
|
||||
'disable_existing_loggers': False,
|
||||
'handlers': {
|
||||
'console': {
|
||||
'class': 'logging.StreamHandler',
|
||||
},
|
||||
},
|
||||
'loggers': {
|
||||
'': {
|
||||
'handlers': ['console'],
|
||||
'level': 'DEBUG',
|
||||
'propagate': True,
|
||||
},
|
||||
'django': {
|
||||
'handlers': ['console'],
|
||||
'level': 'WARNING',
|
||||
'propagate': True,
|
||||
},
|
||||
'django.request': {
|
||||
'handlers': ['console'],
|
||||
'level': 'DEBUG',
|
||||
'propagate': True,
|
||||
},
|
||||
},
|
||||
}
|
|
@ -1,12 +0,0 @@
|
|||
"""
|
||||
A multi-user, multi-group task management and assignment system for Django.
|
||||
"""
|
||||
__version__ = '2.3.1'
|
||||
|
||||
__author__ = 'Scot Hacker'
|
||||
__email__ = 'shacker@birdhouse.org'
|
||||
|
||||
__url__ = 'https://github.com/shacker/django-todo'
|
||||
__license__ = 'BSD License'
|
||||
|
||||
from . import check
|
|
@ -1,25 +0,0 @@
|
|||
from django.contrib import admin
|
||||
|
||||
from todo.models import Attachment, Comment, Task, TaskList
|
||||
|
||||
|
||||
class TaskAdmin(admin.ModelAdmin):
|
||||
list_display = ("title", "task_list", "completed", "priority", "due_date")
|
||||
list_filter = ("task_list",)
|
||||
ordering = ("priority",)
|
||||
search_fields = ("title",)
|
||||
|
||||
|
||||
class CommentAdmin(admin.ModelAdmin):
|
||||
list_display = ("author", "date", "snippet")
|
||||
|
||||
|
||||
class AttachmentAdmin(admin.ModelAdmin):
|
||||
list_display = ("task", "added_by", "timestamp", "file")
|
||||
autocomplete_fields = ["added_by", "task"]
|
||||
|
||||
|
||||
admin.site.register(TaskList)
|
||||
admin.site.register(Comment, CommentAdmin)
|
||||
admin.site.register(Task, TaskAdmin)
|
||||
admin.site.register(Attachment, AttachmentAdmin)
|
|
@ -1,19 +0,0 @@
|
|||
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
|
|
@ -1,4 +0,0 @@
|
|||
Title,Group,Task List,Created By,Created Date,Due Date,Completed,Assigned To,Note,Priority
|
||||
Make dinner,Scuba Divers,Web project,shacker,,2019-06-14,No,,Please check with mgmt first,3
|
||||
Bake bread,Scuba Divers,Example List,mr_random,2012-03-14,,Yes,,,
|
||||
Bring dessert,Scuba Divers,Web project,user1,2015-06-248,,,user1,Every generation throws a hero up the pop charts,77
|
|
|
@ -1,15 +0,0 @@
|
|||
# The integrated mail queue functionality can enable advanced functionality if
|
||||
# django-autocomplete-light is installed and configured. We can use this module
|
||||
# to check for other installed dependencies in the future.
|
||||
|
||||
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
|
|
@ -1,80 +0,0 @@
|
|||
from django import forms
|
||||
from django.contrib.auth.models import Group
|
||||
from django.forms import ModelForm
|
||||
from todo.models import Task, TaskList
|
||||
|
||||
|
||||
class AddTaskListForm(ModelForm):
|
||||
"""The picklist showing allowable groups to which a new list can be added
|
||||
determines which groups the user belongs to. This queries the form object
|
||||
to derive that list."""
|
||||
|
||||
def __init__(self, user, *args, **kwargs):
|
||||
super(AddTaskListForm, self).__init__(*args, **kwargs)
|
||||
self.fields["group"].queryset = Group.objects.filter(user=user)
|
||||
self.fields["group"].widget.attrs = {
|
||||
"id": "id_group",
|
||||
"class": "custom-select mb-3",
|
||||
"name": "group",
|
||||
}
|
||||
|
||||
class Meta:
|
||||
model = TaskList
|
||||
exclude = ["created_date", "slug"]
|
||||
|
||||
|
||||
class AddEditTaskForm(ModelForm):
|
||||
"""The picklist showing the users to which a new task can be assigned
|
||||
must find other members of the group this TaskList is attached to."""
|
||||
|
||||
def __init__(self, user, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
task_list = kwargs.get("initial").get("task_list")
|
||||
members = task_list.group.user_set.all()
|
||||
self.fields["assigned_to"].queryset = members
|
||||
self.fields["assigned_to"].label_from_instance = lambda obj: "%s (%s)" % (
|
||||
obj.get_full_name(),
|
||||
obj.username,
|
||||
)
|
||||
self.fields["assigned_to"].widget.attrs = {
|
||||
"id": "id_assigned_to",
|
||||
"class": "custom-select mb-3",
|
||||
"name": "assigned_to",
|
||||
}
|
||||
self.fields["task_list"].value = kwargs["initial"]["task_list"].id
|
||||
|
||||
due_date = forms.DateField(widget=forms.DateInput(attrs={"type": "date"}), required=False)
|
||||
|
||||
title = forms.CharField(widget=forms.widgets.TextInput())
|
||||
|
||||
note = forms.CharField(widget=forms.Textarea(), required=False)
|
||||
|
||||
class Meta:
|
||||
model = Task
|
||||
exclude = []
|
||||
|
||||
|
||||
class AddExternalTaskForm(ModelForm):
|
||||
"""Form to allow users who are not part of the GTD system to file a ticket."""
|
||||
|
||||
title = forms.CharField(widget=forms.widgets.TextInput(attrs={"size": 35}), label="Summary")
|
||||
note = forms.CharField(widget=forms.widgets.Textarea(), label="Problem Description")
|
||||
priority = forms.IntegerField(widget=forms.HiddenInput())
|
||||
|
||||
class Meta:
|
||||
model = Task
|
||||
exclude = (
|
||||
"task_list",
|
||||
"created_date",
|
||||
"due_date",
|
||||
"created_by",
|
||||
"assigned_to",
|
||||
"completed",
|
||||
"completed_date",
|
||||
)
|
||||
|
||||
|
||||
class SearchForm(forms.Form):
|
||||
"""Search."""
|
||||
|
||||
q = forms.CharField(widget=forms.widgets.TextInput(attrs={"size": 35}))
|
|
@ -1,9 +0,0 @@
|
|||
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
|
|
@ -1,151 +0,0 @@
|
|||
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']}]"
|
||||
)
|
||||
|
||||
# Due to limitations in MySQL wrt unique_together and TextField (grrr),
|
||||
# we must use a CharField rather than TextField for message_id.
|
||||
# In the unlikeley event that we get a VERY long inbound
|
||||
# message_id, truncate it to the max_length of a MySQL CharField.
|
||||
original_message_id = message["message-id"]
|
||||
message_id = (
|
||||
(original_message_id[:252] + "...")
|
||||
if len(original_message_id) > 255
|
||||
else original_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")
|
|
@ -1,25 +0,0 @@
|
|||
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')
|
|
@ -1,9 +0,0 @@
|
|||
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
|
|
@ -1,100 +0,0 @@
|
|||
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)
|
|
@ -1,146 +0,0 @@
|
|||
import factory
|
||||
from faker import Faker
|
||||
from titlecase import titlecase
|
||||
import random
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.contrib.auth.models import Group
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils.text import slugify
|
||||
|
||||
from todo.models import Task, TaskList
|
||||
|
||||
|
||||
num_lists = 5
|
||||
|
||||
|
||||
def gen_title(tc=True):
|
||||
# faker doesn't provide a way to generate headlines in Title Case, without periods, so make our own.
|
||||
# With arg `tc=True`, Title Cases The Generated Text
|
||||
fake = Faker()
|
||||
thestr = fake.text(max_nb_chars=32).rstrip('.')
|
||||
if tc:
|
||||
thestr = titlecase(thestr)
|
||||
|
||||
return thestr
|
||||
|
||||
|
||||
def gen_content():
|
||||
# faker provides paragraphs as a list; convert with linebreaks
|
||||
fake = Faker()
|
||||
grafs = fake.paragraphs()
|
||||
thestr = ''
|
||||
for g in grafs:
|
||||
thestr += "{}\n\n".format(g)
|
||||
return thestr
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = """Create random list and task data for a few fake users."""
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"-d",
|
||||
"--delete",
|
||||
help="Wipe out existing content before generating new.",
|
||||
action="store_true")
|
||||
|
||||
def handle(self, *args, **options):
|
||||
|
||||
if options.get('delete'):
|
||||
# Wipe out previous contents? Cascade deletes the Tasks from the TaskLists.
|
||||
TaskList.objects.all().delete()
|
||||
print("Content from previous run deleted.")
|
||||
print("Working...")
|
||||
|
||||
fake = Faker() # Use to create user's names
|
||||
|
||||
# Create users and groups, add different users to different groups. Staff user is in both groups.
|
||||
sd_group, created = Group.objects.get_or_create(name='Scuba Divers')
|
||||
bw_group, created = Group.objects.get_or_create(name='Basket Weavers')
|
||||
|
||||
# Put user1 and user2 in one group, user3 and user4 in another
|
||||
usernames = ['user1', 'user2', 'user3', 'user4', 'staffer']
|
||||
for username in usernames:
|
||||
if get_user_model().objects.filter(username=username).exists():
|
||||
user = get_user_model().objects.get(username=username)
|
||||
else:
|
||||
user = get_user_model().objects.create_user(
|
||||
username=username,
|
||||
first_name=fake.first_name(),
|
||||
last_name=fake.last_name(),
|
||||
email="{}@example.com".format(username),
|
||||
password="todo")
|
||||
|
||||
if username in ['user1', 'user2']:
|
||||
user.groups.add(bw_group)
|
||||
|
||||
if username in ['user3', 'user4']:
|
||||
user.groups.add(sd_group)
|
||||
|
||||
if username == 'staffer':
|
||||
user.is_staff = True
|
||||
user.first_name = fake.first_name()
|
||||
user.last_name = fake.last_name()
|
||||
user.save()
|
||||
user.groups.add(bw_group)
|
||||
user.groups.add(sd_group)
|
||||
|
||||
# Create lists with tasks, plus one with fixed name for externally added tasks
|
||||
TaskListFactory.create_batch(5, group=bw_group)
|
||||
TaskListFactory.create_batch(5, group=sd_group)
|
||||
TaskListFactory.create(name="Public Tickets", slug="tickets", group=bw_group)
|
||||
|
||||
print("For each of two groups, created fake tasks in each of {} fake lists.".format(num_lists))
|
||||
|
||||
|
||||
class TaskListFactory(factory.django.DjangoModelFactory):
|
||||
"""Group not generated here - call with group as arg."""
|
||||
|
||||
class Meta:
|
||||
model = TaskList
|
||||
|
||||
name = factory.LazyAttribute(lambda o: gen_title(tc=True))
|
||||
slug = factory.LazyAttribute(lambda o: slugify(o.name))
|
||||
group = None # Pass this in
|
||||
|
||||
@factory.post_generation
|
||||
def add_tasks(self, build, extracted, **kwargs):
|
||||
num = random.randint(5, 25)
|
||||
TaskFactory.create_batch(num, task_list=self)
|
||||
|
||||
|
||||
class TaskFactory(factory.django.DjangoModelFactory):
|
||||
"""TaskList not generated here - call with TaskList as arg."""
|
||||
|
||||
class Meta:
|
||||
model = Task
|
||||
|
||||
title = factory.LazyAttribute(lambda o: gen_title(tc=False))
|
||||
task_list = None # Pass this in
|
||||
note = factory.LazyAttribute(lambda o: gen_content())
|
||||
priority = factory.LazyAttribute(lambda o: random.randint(1, 100))
|
||||
completed = factory.Faker('boolean', chance_of_getting_true=30)
|
||||
created_by = factory.LazyAttribute(lambda o: get_user_model().objects.get(username='staffer')) # Randomized in post
|
||||
created_date = factory.Faker('date_this_year')
|
||||
|
||||
@factory.post_generation
|
||||
def add_details(self, build, extracted, **kwargs):
|
||||
|
||||
fake = Faker() # Use to create user's names
|
||||
taskgroup = self.task_list.group
|
||||
|
||||
self.created_by = taskgroup.user_set.all().order_by('?').first()
|
||||
|
||||
if self.completed:
|
||||
self.completed_date = fake.date_this_year()
|
||||
|
||||
# 1/3 of generated tasks have a due_date
|
||||
if random.randint(1, 3) == 1:
|
||||
self.due_date = fake.date_this_year()
|
||||
|
||||
# 1/3 of generated tasks are assigned to someone in this tasks's group
|
||||
if random.randint(1, 3) == 1:
|
||||
self.assigned_to = taskgroup.user_set.all().order_by('?').first()
|
||||
|
||||
self.save()
|
|
@ -1,57 +0,0 @@
|
|||
import sys
|
||||
from typing import Any
|
||||
from pathlib import Path
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandParser
|
||||
|
||||
from todo.operations.csv_importer import CSVImporter
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = """Import specifically formatted CSV file containing incoming tasks to be loaded.
|
||||
For specfic format of inbound CSV, see data/import_example.csv.
|
||||
For documentation on upsert logic and required fields, see README.md.
|
||||
"""
|
||||
|
||||
def add_arguments(self, parser: CommandParser) -> None:
|
||||
|
||||
parser.add_argument(
|
||||
"-f", "--file", dest="file", default=None, help="File to to inbound CSV file."
|
||||
)
|
||||
|
||||
def handle(self, *args: Any, **options: Any) -> None:
|
||||
# Need a file to proceed
|
||||
if not options.get("file"):
|
||||
print("Sorry, we need a filename to work from.")
|
||||
sys.exit(1)
|
||||
|
||||
filepath = Path(options["file"])
|
||||
|
||||
if not filepath.exists():
|
||||
print(f"Sorry, couldn't find file: {filepath}")
|
||||
sys.exit(1)
|
||||
|
||||
# Encoding "utf-8-sig" means "ignore byte order mark (BOM), which Excel inserts when saving CSVs."
|
||||
with filepath.open(mode="r", encoding="utf-8-sig") as fileobj:
|
||||
importer = CSVImporter()
|
||||
results = importer.upsert(fileobj, as_string_obj=True)
|
||||
|
||||
# Report successes, failures and summaries
|
||||
print()
|
||||
if results["upserts"]:
|
||||
for upsert_msg in results["upserts"]:
|
||||
print(upsert_msg)
|
||||
|
||||
# Stored errors has the form:
|
||||
# self.errors = [{3: ["Incorrect foo", "Non-existent bar"]}, {7: [...]}]
|
||||
if results["errors"]:
|
||||
for error_dict in results["errors"]:
|
||||
for k, error_list in error_dict.items():
|
||||
print(f"\nSkipped CSV row {k}:")
|
||||
for msg in error_list:
|
||||
print(f"- {msg}")
|
||||
|
||||
print()
|
||||
if results["summaries"]:
|
||||
for summary_msg in results["summaries"]:
|
||||
print(summary_msg)
|
|
@ -1,44 +0,0 @@
|
|||
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())
|
|
@ -1,78 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import models, migrations
|
||||
import datetime
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('auth', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Comment',
|
||||
fields=[
|
||||
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
|
||||
('date', models.DateTimeField(default=datetime.datetime.now)),
|
||||
('body', models.TextField(blank=True)),
|
||||
('author', models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)),
|
||||
],
|
||||
options={
|
||||
},
|
||||
bases=(models.Model,),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Item',
|
||||
fields=[
|
||||
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
|
||||
('title', models.CharField(max_length=140)),
|
||||
('created_date', models.DateField(auto_now=True, auto_now_add=True)),
|
||||
('due_date', models.DateField(null=True, blank=True)),
|
||||
('completed', models.BooleanField(default=None)),
|
||||
('completed_date', models.DateField(null=True, blank=True)),
|
||||
('note', models.TextField(null=True, blank=True)),
|
||||
('priority', models.PositiveIntegerField(max_length=3)),
|
||||
('assigned_to', models.ForeignKey(related_name='todo_assigned_to', to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)),
|
||||
('created_by', models.ForeignKey(related_name='todo_created_by', to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['priority'],
|
||||
},
|
||||
bases=(models.Model,),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='List',
|
||||
fields=[
|
||||
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
|
||||
('name', models.CharField(max_length=60)),
|
||||
('slug', models.SlugField(max_length=60, editable=False)),
|
||||
('group', models.ForeignKey(to='auth.Group', on_delete=models.CASCADE)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['name'],
|
||||
'verbose_name_plural': 'Lists',
|
||||
},
|
||||
bases=(models.Model,),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='list',
|
||||
unique_together=set([('group', 'slug')]),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='item',
|
||||
name='list',
|
||||
field=models.ForeignKey(to='todo.List', on_delete=models.CASCADE),
|
||||
preserve_default=True,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='comment',
|
||||
name='task',
|
||||
field=models.ForeignKey(to='todo.Item', on_delete=models.CASCADE),
|
||||
preserve_default=True,
|
||||
),
|
||||
]
|
|
@ -1,24 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import models, migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('todo', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='item',
|
||||
name='created_date',
|
||||
field=models.DateField(auto_now=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='item',
|
||||
name='priority',
|
||||
field=models.PositiveIntegerField(),
|
||||
),
|
||||
]
|
|
@ -1,22 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9.5 on 2016-04-09 11:11
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('todo', '0002_auto_20150614_2339'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='item',
|
||||
name='assigned_to',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='todo_assigned_to', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
|
@ -1,52 +0,0 @@
|
|||
# Generated by Django 2.0.2 on 2018-02-09 23:15
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('auth', '0009_alter_user_last_name_max_length'),
|
||||
('todo', '0003_assignee_optional'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='TaskList',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=60)),
|
||||
('slug', models.SlugField(default='')),
|
||||
('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='auth.Group')),
|
||||
],
|
||||
options={
|
||||
'verbose_name_plural': 'Lists',
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='list',
|
||||
unique_together=set(),
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='list',
|
||||
name='group',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='item',
|
||||
name='list',
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='List',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='item',
|
||||
name='task_list',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='todo.TaskList'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='tasklist',
|
||||
unique_together={('group', 'slug')},
|
||||
),
|
||||
]
|
|
@ -1,22 +0,0 @@
|
|||
# Generated by Django 2.0.2 on 2018-02-12 23:25
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('todo', '0004_rename_list_tasklist'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='tasklist',
|
||||
options={'ordering': ['name'], 'verbose_name_plural': 'Task Lists'},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='item',
|
||||
name='completed',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
|
@ -1,19 +0,0 @@
|
|||
# Generated by Django 2.0.3 on 2018-03-28 22:40
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
atomic = False
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('todo', '0005_auto_20180212_2325'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameModel(
|
||||
old_name='Item',
|
||||
new_name='Task',
|
||||
),
|
||||
]
|
|
@ -1,19 +0,0 @@
|
|||
# Generated by Django 2.0.4 on 2018-04-05 00:24
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.utils.timezone
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('todo', '0006_rename_item_model'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='task',
|
||||
name='created_date',
|
||||
field=models.DateField(blank=True, default=django.utils.timezone.now, null=True),
|
||||
),
|
||||
]
|
|
@ -1,46 +0,0 @@
|
|||
# Generated by Django 2.1.7 on 2019-03-24 22:50
|
||||
|
||||
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.CharField(blank=True, max_length=255, 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,22 +0,0 @@
|
|||
# Generated by Django 2.1.7 on 2019-03-18 23:14
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('todo', '0008_mail_tracker'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='task',
|
||||
options={'ordering': ['priority', 'created_date']},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='task',
|
||||
name='priority',
|
||||
field=models.PositiveIntegerField(blank=True, null=True),
|
||||
),
|
||||
]
|
|
@ -1,28 +0,0 @@
|
|||
# Generated by Django 2.2 on 2019-04-06 16:28
|
||||
|
||||
import datetime
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import todo.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('todo', '0009_priority_optional'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Attachment',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('timestamp', models.DateTimeField(default=datetime.datetime.now)),
|
||||
('file', models.FileField(max_length=255, upload_to=todo.models.get_attachment_upload_dir)),
|
||||
('added_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='todo.Task')),
|
||||
],
|
||||
),
|
||||
]
|
190
todo/models.py
190
todo/models.py
|
@ -1,190 +0,0 @@
|
|||
from __future__ import unicode_literals
|
||||
|
||||
import datetime
|
||||
import os
|
||||
import textwrap
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import Group
|
||||
from django.db import DEFAULT_DB_ALIAS, models
|
||||
from django.db.transaction import Atomic, get_connection
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
def get_attachment_upload_dir(instance, filename):
|
||||
"""Determine upload dir for task attachment files.
|
||||
"""
|
||||
|
||||
return "/".join(["tasks", "attachments", str(instance.task.id), filename])
|
||||
|
||||
|
||||
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):
|
||||
name = models.CharField(max_length=60)
|
||||
slug = models.SlugField(default="")
|
||||
group = models.ForeignKey(Group, on_delete=models.CASCADE)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class Meta:
|
||||
ordering = ["name"]
|
||||
verbose_name_plural = "Task Lists"
|
||||
|
||||
# Prevents (at the database level) creation of two lists with the same slug in the same group
|
||||
unique_together = ("group", "slug")
|
||||
|
||||
|
||||
class Task(models.Model):
|
||||
title = models.CharField(max_length=140)
|
||||
task_list = models.ForeignKey(TaskList, on_delete=models.CASCADE, null=True)
|
||||
created_date = models.DateField(default=timezone.now, blank=True, null=True)
|
||||
due_date = models.DateField(blank=True, null=True)
|
||||
completed = models.BooleanField(default=False)
|
||||
completed_date = models.DateField(blank=True, null=True)
|
||||
created_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
related_name="todo_created_by",
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
assigned_to = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
blank=True,
|
||||
null=True,
|
||||
related_name="todo_assigned_to",
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
note = models.TextField(blank=True, null=True)
|
||||
priority = models.PositiveIntegerField(blank=True, null=True)
|
||||
|
||||
# Has due date for an instance of this object passed?
|
||||
def overdue_status(self):
|
||||
"Returns whether the Tasks's due date has passed or not."
|
||||
if self.due_date and datetime.date.today() > self.due_date:
|
||||
return True
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("todo:task_detail", kwargs={"task_id": self.id})
|
||||
|
||||
# Auto-set the Task creation / completed date
|
||||
def save(self, **kwargs):
|
||||
# If Task is being marked complete, set the completed_date
|
||||
if self.completed:
|
||||
self.completed_date = datetime.datetime.now()
|
||||
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:
|
||||
ordering = ["priority", "created_date"]
|
||||
|
||||
|
||||
class Comment(models.Model):
|
||||
"""
|
||||
Not using Django's built-in comments because we want to be able to save
|
||||
a comment and change task details at the same time. Rolling our own since it's easy.
|
||||
"""
|
||||
|
||||
author = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, blank=True, null=True
|
||||
)
|
||||
task = models.ForeignKey(Task, on_delete=models.CASCADE)
|
||||
date = models.DateTimeField(default=datetime.datetime.now)
|
||||
email_from = models.CharField(max_length=320, blank=True, null=True)
|
||||
email_message_id = models.CharField(max_length=255, blank=True, null=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):
|
||||
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
|
||||
return "{author} - {snippet}...".format(
|
||||
author=self.author_text, snippet=body_snippet
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.snippet
|
||||
|
||||
|
||||
class Attachment(models.Model):
|
||||
"""
|
||||
Defines a generic file attachment for use in M2M relation with Task.
|
||||
"""
|
||||
|
||||
task = models.ForeignKey(Task, on_delete=models.CASCADE)
|
||||
added_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, on_delete=models.CASCADE
|
||||
)
|
||||
timestamp = models.DateTimeField(default=datetime.datetime.now)
|
||||
file = models.FileField(upload_to=get_attachment_upload_dir, max_length=255)
|
||||
|
||||
def filename(self):
|
||||
return os.path.basename(self.file.name)
|
||||
|
||||
def extension(self):
|
||||
name, extension = os.path.splitext(self.file.name)
|
||||
return extension
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.task.id} - {self.file.name}"
|
|
@ -1,197 +0,0 @@
|
|||
import codecs
|
||||
import csv
|
||||
import datetime
|
||||
import logging
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Group
|
||||
|
||||
from todo.models import Task, TaskList
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CSVImporter:
|
||||
"""Core upsert functionality for CSV import, for re-use by `import_csv` management command, web UI and tests.
|
||||
Supplies a detailed log of what was and was not imported at the end. See README for usage notes.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.errors = []
|
||||
self.upserts = []
|
||||
self.summaries = []
|
||||
self.line_count = 0
|
||||
self.upsert_count = 0
|
||||
|
||||
def upsert(self, fileobj, as_string_obj=False):
|
||||
"""Expects a file *object*, not a file path. This is important because this has to work for both
|
||||
the management command and the web uploader; the web uploader will pass in in-memory file
|
||||
with no path!
|
||||
|
||||
Header row is:
|
||||
Title, Group, Task List, Created Date, Due Date, Completed, Created By, Assigned To, Note, Priority
|
||||
"""
|
||||
|
||||
if as_string_obj:
|
||||
# fileobj comes from mgmt command
|
||||
csv_reader = csv.DictReader(fileobj)
|
||||
else:
|
||||
# fileobj comes from browser upload (in-memory)
|
||||
csv_reader = csv.DictReader(codecs.iterdecode(fileobj, "utf-8"))
|
||||
|
||||
# DI check: Do we have expected header row?
|
||||
header = csv_reader.fieldnames
|
||||
expected = [
|
||||
"Title",
|
||||
"Group",
|
||||
"Task List",
|
||||
"Created By",
|
||||
"Created Date",
|
||||
"Due Date",
|
||||
"Completed",
|
||||
"Assigned To",
|
||||
"Note",
|
||||
"Priority",
|
||||
]
|
||||
if header != expected:
|
||||
self.errors.append(
|
||||
f"Inbound data does not have expected columns.\nShould be: {expected}"
|
||||
)
|
||||
return
|
||||
|
||||
for row in csv_reader:
|
||||
self.line_count += 1
|
||||
|
||||
newrow = self.validate_row(row)
|
||||
if newrow:
|
||||
# newrow at this point is fully validated, and all FK relations exist,
|
||||
# e.g. `newrow.get("Assigned To")`, is a Django User instance.
|
||||
assignee = newrow.get("Assigned To") if newrow.get("Assigned To") else None
|
||||
created_date = newrow.get("Created Date") if newrow.get("Created Date") else datetime.datetime.today()
|
||||
due_date = newrow.get("Due Date") if newrow.get("Due Date") else None
|
||||
priority = newrow.get("Priority") if newrow.get("Priority") else None
|
||||
|
||||
obj, created = Task.objects.update_or_create(
|
||||
created_by=newrow.get("Created By"),
|
||||
task_list=newrow.get("Task List"),
|
||||
title=newrow.get("Title"),
|
||||
defaults={
|
||||
"assigned_to": assignee,
|
||||
"completed": newrow.get("Completed"),
|
||||
"created_date": created_date,
|
||||
"due_date": due_date,
|
||||
"note": newrow.get("Note"),
|
||||
"priority": priority,
|
||||
},
|
||||
)
|
||||
self.upsert_count += 1
|
||||
msg = (
|
||||
f'Upserted task {obj.id}: "{obj.title}"'
|
||||
f' in list "{obj.task_list}" (group "{obj.task_list.group}")'
|
||||
)
|
||||
self.upserts.append(msg)
|
||||
|
||||
self.summaries.append(f"Processed {self.line_count} CSV rows")
|
||||
self.summaries.append(f"Upserted {self.upsert_count} rows")
|
||||
self.summaries.append(f"Skipped {self.line_count - self.upsert_count} rows")
|
||||
|
||||
return {"summaries": self.summaries, "upserts": self.upserts, "errors": self.errors}
|
||||
|
||||
def validate_row(self, row):
|
||||
"""Perform data integrity checks and set default values. Returns a valid object for insertion, or False.
|
||||
Errors are stored for later display. Intentionally not broken up into separate validator functions because
|
||||
there are interdpendencies, such as checking for existing `creator` in one place and then using
|
||||
that creator for group membership check in others."""
|
||||
|
||||
row_errors = []
|
||||
|
||||
# #######################
|
||||
# Task creator must exist
|
||||
if not row.get("Created By"):
|
||||
msg = f"Missing required task creator."
|
||||
row_errors.append(msg)
|
||||
|
||||
creator = get_user_model().objects.filter(username=row.get("Created By")).first()
|
||||
if not creator:
|
||||
msg = f"Invalid task creator {row.get('Created By')}"
|
||||
row_errors.append(msg)
|
||||
|
||||
# #######################
|
||||
# If specified, Assignee must exist
|
||||
assignee = None # Perfectly valid
|
||||
if row.get("Assigned To"):
|
||||
assigned = get_user_model().objects.filter(username=row.get("Assigned To"))
|
||||
if assigned.exists():
|
||||
assignee = assigned.first()
|
||||
else:
|
||||
msg = f"Missing or invalid task assignee {row.get('Assigned To')}"
|
||||
row_errors.append(msg)
|
||||
|
||||
# #######################
|
||||
# Group must exist
|
||||
try:
|
||||
target_group = Group.objects.get(name=row.get("Group"))
|
||||
except Group.DoesNotExist:
|
||||
msg = f"Could not find group {row.get('Group')}."
|
||||
row_errors.append(msg)
|
||||
target_group = None
|
||||
|
||||
# #######################
|
||||
# Task creator must be in the target group
|
||||
if creator and target_group not in creator.groups.all():
|
||||
msg = f"{creator} is not in group {target_group}"
|
||||
row_errors.append(msg)
|
||||
|
||||
# #######################
|
||||
# Assignee must be in the target group
|
||||
if assignee and target_group not in assignee.groups.all():
|
||||
msg = f"{assignee} is not in group {target_group}"
|
||||
row_errors.append(msg)
|
||||
|
||||
# #######################
|
||||
# Task list must exist in the target group
|
||||
try:
|
||||
tasklist = TaskList.objects.get(name=row.get("Task List"), group=target_group)
|
||||
row["Task List"] = tasklist
|
||||
except TaskList.DoesNotExist:
|
||||
msg = f"Task list {row.get('Task List')} in group {target_group} does not exist"
|
||||
row_errors.append(msg)
|
||||
|
||||
# #######################
|
||||
# Validate Dates
|
||||
datefields = ["Due Date", "Created Date"]
|
||||
for datefield in datefields:
|
||||
datestring = row.get(datefield)
|
||||
if datestring:
|
||||
valid_date = self.validate_date(datestring)
|
||||
if valid_date:
|
||||
row[datefield] = valid_date
|
||||
else:
|
||||
msg = f"Could not convert {datefield} {datestring} to valid date instance"
|
||||
row_errors.append(msg)
|
||||
|
||||
# #######################
|
||||
# Group membership checks have passed
|
||||
row["Created By"] = creator
|
||||
row["Group"] = target_group
|
||||
if assignee:
|
||||
row["Assigned To"] = assignee
|
||||
|
||||
# Set Completed
|
||||
row["Completed"] = (row["Completed"] == "Yes")
|
||||
|
||||
# #######################
|
||||
if row_errors:
|
||||
self.errors.append({self.line_count: row_errors})
|
||||
return False
|
||||
|
||||
# No errors:
|
||||
return row
|
||||
|
||||
def validate_date(self, datestring):
|
||||
"""Inbound date string from CSV translates to a valid python date."""
|
||||
try:
|
||||
date_obj = datetime.datetime.strptime(datestring, "%Y-%m-%d")
|
||||
return date_obj
|
||||
except ValueError:
|
||||
return False
|
|
@ -1,4 +0,0 @@
|
|||
label {
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
}
|
|
@ -1,382 +0,0 @@
|
|||
/**
|
||||
* TableDnD plug-in for JQuery, allows you to drag and drop table rows
|
||||
* You can set up various options to control how the system will work
|
||||
* Copyright (c) Denis Howlett <denish@isocra.com>
|
||||
* Licensed like jQuery, see http://docs.jquery.com/License.
|
||||
*
|
||||
* Configuration options:
|
||||
*
|
||||
* onDragStyle
|
||||
* This is the style that is assigned to the row during drag. There are limitations to the styles that can be
|
||||
* associated with a row (such as you can't assign a border--well you can, but it won't be
|
||||
* displayed). (So instead consider using onDragClass.) The CSS style to apply is specified as
|
||||
* a map (as used in the jQuery css(...) function).
|
||||
* onDropStyle
|
||||
* This is the style that is assigned to the row when it is dropped. As for onDragStyle, there are limitations
|
||||
* to what you can do. Also this replaces the original style, so again consider using onDragClass which
|
||||
* is simply added and then removed on drop.
|
||||
* onDragClass
|
||||
* This class is added for the duration of the drag and then removed when the row is dropped. It is more
|
||||
* flexible than using onDragStyle since it can be inherited by the row cells and other content. The default
|
||||
* is class is tDnD_whileDrag. So to use the default, simply customise this CSS class in your
|
||||
* stylesheet.
|
||||
* onDrop
|
||||
* Pass a function that will be called when the row is dropped. The function takes 2 parameters: the table
|
||||
* and the row that was dropped. You can work out the new order of the rows by using
|
||||
* table.rows.
|
||||
* onDragStart
|
||||
* Pass a function that will be called when the user starts dragging. The function takes 2 parameters: the
|
||||
* table and the row which the user has started to drag.
|
||||
* onAllowDrop
|
||||
* Pass a function that will be called as a row is over another row. If the function returns true, allow
|
||||
* dropping on that row, otherwise not. The function takes 2 parameters: the dragged row and the row under
|
||||
* the cursor. It returns a boolean: true allows the drop, false doesn't allow it.
|
||||
* scrollAmount
|
||||
* This is the number of pixels to scroll if the user moves the mouse cursor to the top or bottom of the
|
||||
* window. The page should automatically scroll up or down as appropriate (tested in IE6, IE7, Safari, FF2,
|
||||
* FF3 beta
|
||||
* dragHandle
|
||||
* This is the name of a class that you assign to one or more cells in each row that is draggable. If you
|
||||
* specify this class, then you are responsible for setting cursor: move in the CSS and only these cells
|
||||
* will have the drag behaviour. If you do not specify a dragHandle, then you get the old behaviour where
|
||||
* the whole row is draggable.
|
||||
*
|
||||
* Other ways to control behaviour:
|
||||
*
|
||||
* Add class="nodrop" to any rows for which you don't want to allow dropping, and class="nodrag" to any rows
|
||||
* that you don't want to be draggable.
|
||||
*
|
||||
* Inside the onDrop method you can also call $.tableDnD.serialize() this returns a string of the form
|
||||
* <tableID>[]=<rowID1>&<tableID>[]=<rowID2> so that you can send this back to the server. The table must have
|
||||
* an ID as must all the rows.
|
||||
*
|
||||
* Other methods:
|
||||
*
|
||||
* $("...").tableDnDUpdate()
|
||||
* Will update all the matching tables, that is it will reapply the mousedown method to the rows (or handle cells).
|
||||
* This is useful if you have updated the table rows using Ajax and you want to make the table draggable again.
|
||||
* The table maintains the original configuration (so you don't have to specify it again).
|
||||
*
|
||||
* $("...").tableDnDSerialize()
|
||||
* Will serialize and return the serialized string as above, but for each of the matching tables--so it can be
|
||||
* called from anywhere and isn't dependent on the currentTable being set up correctly before calling
|
||||
*
|
||||
* Known problems:
|
||||
* - Auto-scoll has some problems with IE7 (it scrolls even when it shouldn't), work-around: set scrollAmount to 0
|
||||
*
|
||||
* Version 0.2: 2008-02-20 First public version
|
||||
* Version 0.3: 2008-02-07 Added onDragStart option
|
||||
* Made the scroll amount configurable (default is 5 as before)
|
||||
* Version 0.4: 2008-03-15 Changed the noDrag/noDrop attributes to nodrag/nodrop classes
|
||||
* Added onAllowDrop to control dropping
|
||||
* Fixed a bug which meant that you couldn't set the scroll amount in both directions
|
||||
* Added serialize method
|
||||
* Version 0.5: 2008-05-16 Changed so that if you specify a dragHandle class it doesn't make the whole row
|
||||
* draggable
|
||||
* Improved the serialize method to use a default (and settable) regular expression.
|
||||
* Added tableDnDupate() and tableDnDSerialize() to be called when you are outside the table
|
||||
*/
|
||||
jQuery.tableDnD = {
|
||||
/** Keep hold of the current table being dragged */
|
||||
currentTable : null,
|
||||
/** Keep hold of the current drag object if any */
|
||||
dragObject: null,
|
||||
/** The current mouse offset */
|
||||
mouseOffset: null,
|
||||
/** Remember the old value of Y so that we don't do too much processing */
|
||||
oldY: 0,
|
||||
|
||||
/** Actually build the structure */
|
||||
build: function(options) {
|
||||
// Set up the defaults if any
|
||||
|
||||
this.each(function() {
|
||||
// This is bound to each matching table, set up the defaults and override with user options
|
||||
this.tableDnDConfig = jQuery.extend({
|
||||
onDragStyle: null,
|
||||
onDropStyle: null,
|
||||
// Add in the default class for whileDragging
|
||||
onDragClass: "tDnD_whileDrag",
|
||||
onDrop: null,
|
||||
onDragStart: null,
|
||||
scrollAmount: 5,
|
||||
serializeRegexp: /[^\-]*$/, // The regular expression to use to trim row IDs
|
||||
serializeParamName: null, // If you want to specify another parameter name instead of the table ID
|
||||
dragHandle: null // If you give the name of a class here, then only Cells with this class will be draggable
|
||||
}, options || {});
|
||||
// Now make the rows draggable
|
||||
jQuery.tableDnD.makeDraggable(this);
|
||||
});
|
||||
|
||||
// Now we need to capture the mouse up and mouse move event
|
||||
// We can use bind so that we don't interfere with other event handlers
|
||||
jQuery(document)
|
||||
.bind('mousemove', jQuery.tableDnD.mousemove)
|
||||
.bind('mouseup', jQuery.tableDnD.mouseup);
|
||||
|
||||
// Don't break the chain
|
||||
return this;
|
||||
},
|
||||
|
||||
/** This function makes all the rows on the table draggable apart from those marked as "NoDrag" */
|
||||
makeDraggable: function(table) {
|
||||
var config = table.tableDnDConfig;
|
||||
if (table.tableDnDConfig.dragHandle) {
|
||||
// We only need to add the event to the specified cells
|
||||
var cells = jQuery("td."+table.tableDnDConfig.dragHandle, table);
|
||||
cells.each(function() {
|
||||
// The cell is bound to "this"
|
||||
jQuery(this).mousedown(function(ev) {
|
||||
jQuery.tableDnD.dragObject = this.parentNode;
|
||||
jQuery.tableDnD.currentTable = table;
|
||||
jQuery.tableDnD.mouseOffset = jQuery.tableDnD.getMouseOffset(this, ev);
|
||||
if (config.onDragStart) {
|
||||
// Call the onDrop method if there is one
|
||||
config.onDragStart(table, this);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
})
|
||||
} else {
|
||||
// For backwards compatibility, we add the event to the whole row
|
||||
var rows = jQuery("tr", table); // get all the rows as a wrapped set
|
||||
rows.each(function() {
|
||||
// Iterate through each row, the row is bound to "this"
|
||||
var row = jQuery(this);
|
||||
if (! row.hasClass("nodrag")) {
|
||||
row.mousedown(function(ev) {
|
||||
if (ev.target.tagName == "TD") {
|
||||
jQuery.tableDnD.dragObject = this;
|
||||
jQuery.tableDnD.currentTable = table;
|
||||
jQuery.tableDnD.mouseOffset = jQuery.tableDnD.getMouseOffset(this, ev);
|
||||
if (config.onDragStart) {
|
||||
// Call the onDrop method if there is one
|
||||
config.onDragStart(table, this);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}).css("cursor", "move"); // Store the tableDnD object
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
updateTables: function() {
|
||||
this.each(function() {
|
||||
// this is now bound to each matching table
|
||||
if (this.tableDnDConfig) {
|
||||
jQuery.tableDnD.makeDraggable(this);
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
/** Get the mouse coordinates from the event (allowing for browser differences) */
|
||||
mouseCoords: function(ev){
|
||||
if(ev.pageX || ev.pageY){
|
||||
return {x:ev.pageX, y:ev.pageY};
|
||||
}
|
||||
return {
|
||||
x:ev.clientX + document.body.scrollLeft - document.body.clientLeft,
|
||||
y:ev.clientY + document.body.scrollTop - document.body.clientTop
|
||||
};
|
||||
},
|
||||
|
||||
/** Given a target element and a mouse event, get the mouse offset from that element.
|
||||
To do this we need the element's position and the mouse position */
|
||||
getMouseOffset: function(target, ev) {
|
||||
ev = ev || window.event;
|
||||
|
||||
var docPos = this.getPosition(target);
|
||||
var mousePos = this.mouseCoords(ev);
|
||||
return {x:mousePos.x - docPos.x, y:mousePos.y - docPos.y};
|
||||
},
|
||||
|
||||
/** Get the position of an element by going up the DOM tree and adding up all the offsets */
|
||||
getPosition: function(e){
|
||||
var left = 0;
|
||||
var top = 0;
|
||||
/** Safari fix -- thanks to Luis Chato for this! */
|
||||
if (e.offsetHeight == 0) {
|
||||
/** Safari 2 doesn't correctly grab the offsetTop of a table row
|
||||
this is detailed here:
|
||||
http://jacob.peargrove.com/blog/2006/technical/table-row-offsettop-bug-in-safari/
|
||||
the solution is likewise noted there, grab the offset of a table cell in the row - the firstChild.
|
||||
note that firefox will return a text node as a first child, so designing a more thorough
|
||||
solution may need to take that into account, for now this seems to work in firefox, safari, ie */
|
||||
e = e.firstChild; // a table cell
|
||||
}
|
||||
|
||||
while (e.offsetParent){
|
||||
left += e.offsetLeft;
|
||||
top += e.offsetTop;
|
||||
e = e.offsetParent;
|
||||
}
|
||||
|
||||
left += e.offsetLeft;
|
||||
top += e.offsetTop;
|
||||
|
||||
return {x:left, y:top};
|
||||
},
|
||||
|
||||
mousemove: function(ev) {
|
||||
if (jQuery.tableDnD.dragObject == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
var dragObj = jQuery(jQuery.tableDnD.dragObject);
|
||||
var config = jQuery.tableDnD.currentTable.tableDnDConfig;
|
||||
var mousePos = jQuery.tableDnD.mouseCoords(ev);
|
||||
var y = mousePos.y - jQuery.tableDnD.mouseOffset.y;
|
||||
//auto scroll the window
|
||||
var yOffset = window.pageYOffset;
|
||||
if (document.all) {
|
||||
// Windows version
|
||||
//yOffset=document.body.scrollTop;
|
||||
if (typeof document.compatMode != 'undefined' &&
|
||||
document.compatMode != 'BackCompat') {
|
||||
yOffset = document.documentElement.scrollTop;
|
||||
}
|
||||
else if (typeof document.body != 'undefined') {
|
||||
yOffset=document.body.scrollTop;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if (mousePos.y-yOffset < config.scrollAmount) {
|
||||
window.scrollBy(0, -config.scrollAmount);
|
||||
} else {
|
||||
var windowHeight = window.innerHeight ? window.innerHeight
|
||||
: document.documentElement.clientHeight ? document.documentElement.clientHeight : document.body.clientHeight;
|
||||
if (windowHeight-(mousePos.y-yOffset) < config.scrollAmount) {
|
||||
window.scrollBy(0, config.scrollAmount);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (y != jQuery.tableDnD.oldY) {
|
||||
// work out if we're going up or down...
|
||||
var movingDown = y > jQuery.tableDnD.oldY;
|
||||
// update the old value
|
||||
jQuery.tableDnD.oldY = y;
|
||||
// update the style to show we're dragging
|
||||
if (config.onDragClass) {
|
||||
dragObj.addClass(config.onDragClass);
|
||||
} else {
|
||||
dragObj.css(config.onDragStyle);
|
||||
}
|
||||
// If we're over a row then move the dragged row to there so that the user sees the
|
||||
// effect dynamically
|
||||
var currentRow = jQuery.tableDnD.findDropTargetRow(dragObj, y);
|
||||
if (currentRow) {
|
||||
// TODO worry about what happens when there are multiple TBODIES
|
||||
if (movingDown && jQuery.tableDnD.dragObject != currentRow) {
|
||||
jQuery.tableDnD.dragObject.parentNode.insertBefore(jQuery.tableDnD.dragObject, currentRow.nextSibling);
|
||||
} else if (! movingDown && jQuery.tableDnD.dragObject != currentRow) {
|
||||
jQuery.tableDnD.dragObject.parentNode.insertBefore(jQuery.tableDnD.dragObject, currentRow);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
/** We're only worried about the y position really, because we can only move rows up and down */
|
||||
findDropTargetRow: function(draggedRow, y) {
|
||||
var rows = jQuery.tableDnD.currentTable.rows;
|
||||
for (var i=0; i<rows.length; i++) {
|
||||
var row = rows[i];
|
||||
var rowY = this.getPosition(row).y;
|
||||
var rowHeight = parseInt(row.offsetHeight)/2;
|
||||
if (row.offsetHeight == 0) {
|
||||
rowY = this.getPosition(row.firstChild).y;
|
||||
rowHeight = parseInt(row.firstChild.offsetHeight)/2;
|
||||
}
|
||||
// Because we always have to insert before, we need to offset the height a bit
|
||||
if ((y > rowY - rowHeight) && (y < (rowY + rowHeight))) {
|
||||
// that's the row we're over
|
||||
// If it's the same as the current row, ignore it
|
||||
if (row == draggedRow) {return null;}
|
||||
var config = jQuery.tableDnD.currentTable.tableDnDConfig;
|
||||
if (config.onAllowDrop) {
|
||||
if (config.onAllowDrop(draggedRow, row)) {
|
||||
return row;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
// If a row has nodrop class, then don't allow dropping (inspired by John Tarr and Famic)
|
||||
var nodrop = jQuery(row).hasClass("nodrop");
|
||||
if (! nodrop) {
|
||||
return row;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return row;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
mouseup: function(e) {
|
||||
if (jQuery.tableDnD.currentTable && jQuery.tableDnD.dragObject) {
|
||||
var droppedRow = jQuery.tableDnD.dragObject;
|
||||
var config = jQuery.tableDnD.currentTable.tableDnDConfig;
|
||||
// If we have a dragObject, then we need to release it,
|
||||
// The row will already have been moved to the right place so we just reset stuff
|
||||
if (config.onDragClass) {
|
||||
jQuery(droppedRow).removeClass(config.onDragClass);
|
||||
} else {
|
||||
jQuery(droppedRow).css(config.onDropStyle);
|
||||
}
|
||||
jQuery.tableDnD.dragObject = null;
|
||||
if (config.onDrop) {
|
||||
// Call the onDrop method if there is one
|
||||
config.onDrop(jQuery.tableDnD.currentTable, droppedRow);
|
||||
}
|
||||
jQuery.tableDnD.currentTable = null; // let go of the table too
|
||||
}
|
||||
},
|
||||
|
||||
serialize: function() {
|
||||
if (jQuery.tableDnD.currentTable) {
|
||||
return jQuery.tableDnD.serializeTable(jQuery.tableDnD.currentTable);
|
||||
} else {
|
||||
return "Error: No Table id set, you need to set an id on your table and every row";
|
||||
}
|
||||
},
|
||||
|
||||
serializeTable: function(table) {
|
||||
var result = "";
|
||||
var tableId = table.id;
|
||||
var rows = table.rows;
|
||||
for (var i=0; i<rows.length; i++) {
|
||||
if (result.length > 0) result += "&";
|
||||
var rowId = rows[i].id;
|
||||
if (rowId && rowId && table.tableDnDConfig && table.tableDnDConfig.serializeRegexp) {
|
||||
rowId = rowId.match(table.tableDnDConfig.serializeRegexp)[0];
|
||||
}
|
||||
|
||||
result += tableId + '[]=' + rowId;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
serializeTables: function() {
|
||||
var result = "";
|
||||
this.each(function() {
|
||||
// this is now bound to each matching table
|
||||
result += jQuery.tableDnD.serializeTable(this);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
jQuery.fn.extend(
|
||||
{
|
||||
tableDnD : jQuery.tableDnD.build,
|
||||
tableDnDUpdate : jQuery.tableDnD.updateTables,
|
||||
tableDnDSerialize: jQuery.tableDnD.serializeTables
|
||||
}
|
||||
);
|
|
@ -1 +0,0 @@
|
|||
This file not actually used by django-todo - here to satisfy the test runner.
|
|
@ -1,23 +0,0 @@
|
|||
{% extends "todo/base.html" %}
|
||||
{% block page_heading %}{% endblock %}
|
||||
{% block title %}Add Todo List{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<h2>Add a list:</h2>
|
||||
|
||||
<form action="" method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="form-group">
|
||||
<label for="id_name">List Name</label>
|
||||
<input type="text" class="form-control" id="id_name" name="name" aria-describedby="inputNameHelp" placeholder="">
|
||||
<small id="inputNameHelp" class="form-text text-muted">The full display name for this list.</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="id_group">Group</label>
|
||||
{{form.group}}
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Submit</button>
|
||||
</form>
|
||||
{% endblock %}
|
|
@ -1,47 +0,0 @@
|
|||
{% extends "todo/base.html" %}
|
||||
{% block page_heading %}{% endblock %}
|
||||
{% block title %}File Ticket{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h2>{{ task }}</h2>
|
||||
|
||||
<form action="" method="POST">
|
||||
{% csrf_token %}
|
||||
|
||||
<h3>File Trouble Ticket</h3>
|
||||
<p>
|
||||
Have a support issue? Use this form to report the difficulty - we'll get right back to you.
|
||||
</p>
|
||||
|
||||
{% if form.errors %}
|
||||
{% for error in form.errors %}
|
||||
<ul class="errorlist">
|
||||
<li>
|
||||
<strong>The {{ error|escape }} field is required.</strong>
|
||||
</li>
|
||||
</ul>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<form action="" name="add_task" method="post">
|
||||
{% csrf_token %}
|
||||
<div class="form-group">
|
||||
<label for="id_title" name="title">Subject</label>
|
||||
<input type="text" class="form-control" id="id_title" name="title" required placeholder="Our spaceship doesn't go"
|
||||
value="{% if form.title.value %}{{ form.title.value }}{% endif %}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="id_note">Description</label>
|
||||
<textarea class="form-control" id="id_note" name="note" rows="5"
|
||||
aria-describedby="inputNoteHelp">{% if form.note.value %}{{ form.note.value }}{% endif %}</textarea>
|
||||
<small id="inputNoteHelp" class="form-text text-muted">
|
||||
Describe the issue. Please include essential details.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<input type="hidden" id="id_priority" name="priority" value="50">
|
||||
<p><input type="submit" class="btn btn-primary" name="add_task" value="Submit"></p>
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
|
@ -1,7 +0,0 @@
|
|||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block extrahead %}
|
||||
<!-- CSS and JavaScripts for django-todo -->
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'todo/css/styles.css' %}" />
|
||||
{% endblock extrahead %}
|
|
@ -1,33 +0,0 @@
|
|||
{% extends "todo/base.html" %}
|
||||
{% block title %}Delete list{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if user.is_staff %}
|
||||
<h1>Delete entire list: {{ task_list.name }} ?</h1>
|
||||
|
||||
<p>Category tally:</p>
|
||||
|
||||
<ul>
|
||||
<li>Incomplete: {{ task_count_undone }} </li>
|
||||
<li>Complete: {{ task_count_done }} </li>
|
||||
<li>
|
||||
<strong>Total: {{ task_count_total }}</strong>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p> ... all of which will be irretrievably
|
||||
<strong>blown away</strong>. Are you sure you want to do that?</p>
|
||||
|
||||
<form action="" method="post" accept-charset="utf-8">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="task_list" value="{{ task_list.id }}">
|
||||
<p>
|
||||
<a href="{% url 'todo:list_detail' task_list.id task_list.slug %}" class="btn btn-success">Return to list: {{ task_list.name }}</a>
|
||||
<input type="submit" name="delete-confirm" value="Do it! →" class="btn btn-danger">
|
||||
</p>
|
||||
</form>
|
||||
|
||||
|
||||
{% else %}
|
||||
<p>Sorry, you don't have permission to delete lists. Please contact your group administrator.</p>
|
||||
{% endif %} {% endblock %}
|
|
@ -1,17 +0,0 @@
|
|||
{{ task.assigned_to.first_name }} -
|
||||
|
||||
A new task on the list {{ task.task_list.name }} has been assigned to you by {{ task.created_by.get_full_name }}:
|
||||
|
||||
{{ task.title }}
|
||||
|
||||
{% if task.note %}
|
||||
{% autoescape off %}
|
||||
Note: {{ task.note }}
|
||||
{% endautoescape %}
|
||||
{% endif %}
|
||||
|
||||
Task details/comments:
|
||||
http://{{ site }}{% url 'todo:task_detail' task.id %}
|
||||
|
||||
List {{ task.task_list.name }}:
|
||||
http://{{ site }}{% url 'todo:list_detail' task.task_list.id task.task_list.slug %}
|
|
@ -1 +0,0 @@
|
|||
A new task has been assigned to you - {% autoescape off %}{{ task.title }}{% endautoescape %}
|
|
@ -1,16 +0,0 @@
|
|||
A new task comment has been added.
|
||||
|
||||
Task: {{ task.title }}
|
||||
Commenter: {{ user.first_name }} {{ user.last_name }}
|
||||
|
||||
Comment:
|
||||
{% autoescape off %}
|
||||
{{ body }}
|
||||
{% endautoescape %}
|
||||
|
||||
Task details/comments:
|
||||
https://{{ site }}{% url 'todo:task_detail' task.id %}
|
||||
|
||||
List {{ task.task_list.name }}:
|
||||
https://{{ site }}{% url 'todo:list_detail' task.task_list.id task.task_list.slug %}
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
{% extends "todo/base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Import CSV{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2>
|
||||
Import CSV
|
||||
</h2>
|
||||
|
||||
<p>
|
||||
Batch-import tasks by uploading a specifically-formatted CSV.
|
||||
See documentation for formatting rules.
|
||||
Successs and failures will be reported here.
|
||||
</p>
|
||||
|
||||
{% if results %}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
Results of CSV upload
|
||||
</div>
|
||||
<div class="card-body">
|
||||
|
||||
{% if results.summaries %}
|
||||
<p>
|
||||
<b>Summary:</b>
|
||||
</p>
|
||||
<ul>
|
||||
{% for line in results.summaries %}
|
||||
<li>{{ line }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{% if results.upserts %}
|
||||
<p>
|
||||
<b>Upserts (tasks created or updated):</b>
|
||||
</p>
|
||||
<ul>
|
||||
{% for line in results.upserts %}
|
||||
<li>{{ line }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{% if results.errors %}
|
||||
<p>
|
||||
<b>Errors (tasks NOT created or updated):</b>
|
||||
</p>
|
||||
<ul>
|
||||
{% for error_row in results.errors %}
|
||||
{% for k, error_list in error_row.items %}
|
||||
<li>CSV row {{ k }}</li>
|
||||
<ul>
|
||||
{% for err in error_list %}
|
||||
<li>{{ err }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
Upload Tasks
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
<div>
|
||||
<input type="file" name="csvfile" accept="text/csv">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary mt-4">Upload</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
|
@ -1,55 +0,0 @@
|
|||
{# Form used by both Add Task and Edit Task views #}
|
||||
|
||||
<form action="" name="add_task" method="post">
|
||||
{% csrf_token %}
|
||||
<div class="mt-3">
|
||||
<div class="form-group">
|
||||
<label for="id_title" name="title">Task</label>
|
||||
<input type="text" class="form-control" id="id_title" name="title" required placeholder="Task title"
|
||||
value="{% if form.title.value %}{{ form.title.value }}{% endif %}">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="id_note">Description</label>
|
||||
<textarea class="form-control" id="id_note" name="note" rows="5"
|
||||
aria-describedby="inputNoteHelp">{% if form.note.value %}{{ form.note.value }}{% endif %}</textarea>
|
||||
<small id="inputNoteHelp" class="form-text text-muted">
|
||||
Describe the task or bug. Provide steps to reproduce the issue.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="id_due_date">Due Date</label>
|
||||
<input type="date" class="form-control" id="id_due_date" name="due_date"
|
||||
value="{% if form.due_date.value %}{{ form.due_date.value|date:"Y-m-d" }}{% endif %}">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="id_assigned_to">Assigned To</label>
|
||||
{# See todo.forms.AddEditTaskForm #}
|
||||
{{form.assigned_to}}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" aria-describedby="inputNotifyHelp" checked="checked" id="id_notify">
|
||||
<label class="form-check-label" for="id_notify">
|
||||
Notify
|
||||
</label>
|
||||
<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>
|
||||
|
||||
<input type="hidden" name="priority"
|
||||
value="{% if form.priority.value %}{{ form.priority.value }}{% else %}999{% endif %}" id="id_priority">
|
||||
<input type="hidden" name="created_by" value="{{ request.user.id }}" id="id_created_by">
|
||||
<input type="hidden" name="task_list" value="{{ form.task_list.value }}" id="id_task_list">
|
||||
|
||||
<p>
|
||||
<input type="submit" name="add_edit_task" value="Submit" class="btn btn-primary">
|
||||
</p>
|
||||
|
||||
</div>
|
||||
</form>
|
|
@ -1,12 +0,0 @@
|
|||
|
||||
{% if list_slug != "mine" %}
|
||||
{% if view_completed %}
|
||||
<a href="{% url 'todo:list_detail' list_id list_slug %}" class="btn btn-sm btn-warning">View incomplete tasks</a>
|
||||
|
||||
{% else %}
|
||||
<a href="{% url 'todo:list_detail_completed' list_id list_slug %}" class="btn btn-sm btn-warning">View completed tasks</a>
|
||||
|
||||
{% endif %}
|
||||
|
||||
<a href="{% url 'todo:del_list' list_id list_slug %}" class="btn btn-sm btn-danger">Delete this list</a>
|
||||
{% endif %}
|
|
@ -1,114 +0,0 @@
|
|||
{% extends "todo/base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Todo List: {{ task_list.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% if list_slug != "mine" %}
|
||||
<button class="btn btn-primary" id="AddTaskButton" type="button"
|
||||
data-toggle="collapse" data-target="#AddEditTask">Add Task</button>
|
||||
|
||||
{# Task edit / new task form #}
|
||||
<div id="AddEditTask" class="collapse">
|
||||
{% include 'todo/include/task_edit.html' %}
|
||||
</div>
|
||||
<hr />
|
||||
{% endif %}
|
||||
|
||||
{% if tasks %}
|
||||
{% if list_slug == "mine" %}
|
||||
<h1>Tasks assigned to me (in all groups)</h1>
|
||||
{% else %}
|
||||
<h1>{{ view_completed|yesno:"Completed tasks, Tasks" }} in "{{ task_list.name }}"</h1>
|
||||
<p><small><i>In workgroup "{{ task_list.group }}" - drag rows to set priorities.</i></small></p>
|
||||
{% endif %}
|
||||
|
||||
<table class="table" id="tasktable">
|
||||
<tr class="nodrop">
|
||||
<th>Task</th>
|
||||
<th>Created</th>
|
||||
<th>Due on</th>
|
||||
<th>Owner</th>
|
||||
<th>Assigned</th>
|
||||
<th>Mark</th>
|
||||
</tr>
|
||||
|
||||
{% for task in tasks %}
|
||||
<tr id="{{ task.id }}">
|
||||
<td>
|
||||
<a href="{% url 'todo:task_detail' task.id %}">{{ task.title|truncatewords:10 }}</a>
|
||||
</td>
|
||||
<td>
|
||||
{{ task.created_date|date:"m/d/Y" }}
|
||||
</td>
|
||||
<td>
|
||||
<span {% if task.overdue_status %}class="overdue"{% endif %}>
|
||||
{{ task.due_date|date:"m/d/Y" }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{{ task.created_by }}
|
||||
</td>
|
||||
<td>
|
||||
{% if task.assigned_to %}{{ task.assigned_to }}{% else %}Anyone{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<form method="post" action="{% url "todo:task_toggle_done" task.id %}" role="form">
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-info btn-sm" type="submit" name="toggle_done">
|
||||
{% if view_completed %}
|
||||
Not Done
|
||||
{% else %}
|
||||
Done
|
||||
{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
|
||||
{% include 'todo/include/toggle_delete.html' %}
|
||||
|
||||
{% else %}
|
||||
<h4>No tasks on this list yet (add one!)</h4>
|
||||
{% include 'todo/include/toggle_delete.html' %}
|
||||
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="{% static 'todo/js/jquery.tablednd_0_5.js' %}" type="text/javascript"></script>
|
||||
|
||||
<script type="text/javascript">
|
||||
function order_tasks(data) {
|
||||
// The JQuery plugin tableDnD provides a serialize() function which provides the re-ordered
|
||||
// data in a list. We pass that list as an object ("data") to a Django view
|
||||
// to save new priorities on each task in the list.
|
||||
$.post("{% url 'todo:reorder_tasks' %}", data, "json");
|
||||
return false;
|
||||
};
|
||||
|
||||
$(document).ready(function() {
|
||||
// Initialise the task table for drag/drop re-ordering
|
||||
$("#tasktable").tableDnD();
|
||||
|
||||
$('#tasktable').tableDnD({
|
||||
onDrop: function(table, row) {
|
||||
order_tasks($.tableDnD.serialize());
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// When adding a task, change the text of the Add Task button
|
||||
function handleClick()
|
||||
{
|
||||
console.log(this.innerHTML);
|
||||
this.innerHTML = (this.innerHTML == 'Add Task' ? 'Cancel' : 'Add Task');
|
||||
}
|
||||
document.getElementById('AddTaskButton').onclick=handleClick;
|
||||
</script>
|
||||
{% endblock extra_js %}
|
|
@ -1,33 +0,0 @@
|
|||
{% extends "todo/base.html" %}
|
||||
|
||||
{% block title %}{{ list_title }} Todo Lists{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Todo Lists</h1>
|
||||
|
||||
<p>{{ task_count }} tasks in {{ list_count }} list{{ list_count|pluralize }}</p>
|
||||
|
||||
{% regroup lists by group as section_list %}
|
||||
{% for group in section_list %}
|
||||
<h3>Group: {{ group.grouper }}</h3>
|
||||
<ul class="list-group mb-4">
|
||||
{% for task in group.list %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<a href="{% url 'todo:list_detail' task.id task.slug %}">{{ task.name }}</a>
|
||||
<span class="badge badge-primary badge-pill">{{ task.task_set.count }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endfor %}
|
||||
|
||||
<div class="mt-3">
|
||||
|
||||
{% if user.is_staff %}
|
||||
<a href="{% url 'todo:add_list' %}" class="btn btn-primary">Create new todo list</a>
|
||||
{% else %}
|
||||
<a href="" class="btn btn-primary disabled">If you were staff, you could create a new list</a>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
|
@ -1,30 +0,0 @@
|
|||
{% extends "todo/base.html" %}
|
||||
|
||||
{% block title %}Search results{% endblock %}
|
||||
{% block content_title %}<h2 class="page_title">Search</h2>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if found_tasks %}
|
||||
<h2>{{found_tasks.count}} search results for term: "{{ query_string }}"</h2>
|
||||
<div class="post_list">
|
||||
{% for f in found_tasks %}
|
||||
<p>
|
||||
<strong>
|
||||
<a href="{% url 'todo:task_detail' f.id %}">{{ f.title }}</a>
|
||||
</strong>
|
||||
<br />
|
||||
<span class="minor">
|
||||
In list:
|
||||
<a href="{% url 'todo:list_detail' f.task_list.id f.task_list.slug %}">
|
||||
{{ f.task_list.name }}
|
||||
</a>
|
||||
<br /> Assigned to: {% if f.assigned_to %}{{ f.assigned_to }}{% else %}Anyone{% endif %}
|
||||
<br /> Complete: {{ f.completed|yesno:"Yes,No" }}
|
||||
</span>
|
||||
</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<h2> No results to show, sorry.</h2>
|
||||
{% endif %}
|
||||
{% endblock %}
|
|
@ -1,223 +0,0 @@
|
|||
{% extends "todo/base.html" %}
|
||||
|
||||
{% 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 %}
|
||||
<div class="card-deck">
|
||||
<div class="card col-sm-8">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title">{{ task.title }}</h3>
|
||||
{% if task.note %}
|
||||
<div class="card-text">{{ task.note|safe|urlize|linebreaks }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card col-sm-4 p-0">
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item">
|
||||
<button
|
||||
class="btn btn-sm btn-primary"
|
||||
id="EditTaskButton"
|
||||
type="button"
|
||||
data-toggle="collapse"
|
||||
data-target="#TaskEdit">
|
||||
Edit Task
|
||||
</button>
|
||||
|
||||
<form method="post" action="{% url "todo:task_toggle_done" task.id %}" role="form" class="d-inline">
|
||||
{% csrf_token %}
|
||||
<div style="display:inline;">
|
||||
<button class="btn btn-info btn-sm" type="submit" name="toggle_done">
|
||||
{% if task.completed %} Mark Not Done {% else %} Mark Done {% endif %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<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">
|
||||
<strong>Assigned to:</strong>
|
||||
{% if task.assigned_to %} {{ task.assigned_to.get_full_name }} {% else %} Anyone {% endif %}
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
<strong>Reported by:</strong> {{ task.created_by.get_full_name }}
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
<strong>Due date:</strong> {{ task.due_date }}
|
||||
</li>
|
||||
|
||||
{% if task.completed %}
|
||||
<li class="list-group-item">
|
||||
<strong>Completed on:</strong> {{ task.completed_date}}
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="list-group-item">
|
||||
<strong>Completed:</strong> {{ task.completed|yesno:"Yes,No" }}
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
<li class="list-group-item">
|
||||
<strong>In list:</strong>
|
||||
<a href="{% url 'todo:list_detail' task.task_list.id task.task_list.slug %}">
|
||||
{{ task.task_list }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="TaskEdit" class="collapse">
|
||||
{# Task edit / new task form #}
|
||||
{% 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>
|
||||
|
||||
{% if attachments_enabled %}
|
||||
<div class="card mt-4">
|
||||
<h5 class="card-header">
|
||||
Attachments
|
||||
</h5>
|
||||
|
||||
<div class="card-body pb-0">
|
||||
{% if task.attachment_set.count %}
|
||||
<div class="table-responsive">
|
||||
<table class="table mb-3">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>File</th>
|
||||
<th>Uploaded</th>
|
||||
<th>By</th>
|
||||
<th>Type</th>
|
||||
<th>Remove</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for attachment in task.attachment_set.all %}
|
||||
<tr>
|
||||
<td><a href="{{ attachment.file.url }}">{{ attachment.filename }}</a></td>
|
||||
<td>{{ attachment.timestamp }}</td>
|
||||
<td>{{ attachment.added_by.get_full_name }}</td>
|
||||
<td>{{ attachment.extension.lower }}</td>
|
||||
<td>
|
||||
<form action="{% url "todo:remove_attachment" attachment.id %}" method="POST">
|
||||
{% csrf_token %}
|
||||
<input type="submit" value="X" class="btn btn-danger btn-sm">
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="POST" action="" enctype="multipart/form-data" style="width:50%;">
|
||||
{% csrf_token %}
|
||||
<div class="input-group mb-3">
|
||||
<div class="custom-file">
|
||||
<input type="file" class="custom-file-input" id="attachment_file_input" name="attachment_file_input" />
|
||||
<label class="custom-file-label" for="attachment_file_input">Choose file</label>
|
||||
</div>
|
||||
<div class="input-group-append">
|
||||
<button class="btn btn-primary">Upload</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-3">
|
||||
<h5>Add comment</h5>
|
||||
<form action="" method="post">
|
||||
{% csrf_token %}
|
||||
<div class="form-group">
|
||||
<textarea class="form-control" name="comment-body" rows="3" required></textarea>
|
||||
</div>
|
||||
<input class="btn btn-sm btn-primary" type="submit" name="add_comment" value="Add Comment">
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="task_comments mt-4">
|
||||
{% if comment_list %}
|
||||
<h5>Comments on this task</h5>
|
||||
{% for comment in comment_list %}
|
||||
<div class="mb-3 card">
|
||||
<div class="card-header">
|
||||
<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" }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="{{ comment_classes | join:" " }} card-body">
|
||||
{{ comment.body|safe|urlize|linebreaks }}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<h5>No comments (yet).</h5>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
{# Support file attachment uploader #}
|
||||
<script>
|
||||
$('#attachment_file_input').on('change',function(){
|
||||
// Get the file name and remove browser-added "fakepath."
|
||||
// Then replace the "Choose a file" label.
|
||||
var fileName = $(this).val().replace('C:\\fakepath\\', " ");
|
||||
$(this).next('.custom-file-label').html(fileName);
|
||||
})
|
||||
</script>
|
||||
{% endblock extra_js %}
|
||||
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue