Compare commits

..

68 Commits

Author SHA1 Message Date
hecfernandez 120442206b fix tags exclude filter behaviour (#1246)
* fix tags exclude filter behaviour
2019-02-13 14:20:04 +01:00
Álex Hermida d6d3f8c6a8 Update change log 2019-02-01 21:54:16 +01:00
Miguel Gonzalez 3cebad87eb fix: Sanitize email header removing linefeed and carriage return chars 2019-01-29 13:45:23 +01:00
Álex Hermida f45f5ae08a Activate Ukrainian language 2019-01-29 13:15:19 +01:00
Álex Hermida 0434e8b78b Fix calculate_milestone_is_closed and add tests 2019-01-29 10:30:59 +01:00
Héctor Fernández Cascallar 0c0e09819a refactor concatenation method for keep compatibility 2019-01-22 11:29:20 +01:00
Héctor Fernández Cascallar 2b38fefa13 refactor exclude filter mode implementation 2019-01-22 11:29:20 +01:00
Héctor Fernández Cascallar 7c5ba16d24 refactor prepare filter methods in tags filter 2019-01-22 11:29:20 +01:00
Héctor Fernández Cascallar b0d065167c refactor tags exclude filter implementation 2019-01-22 11:29:20 +01:00
Héctor Fernández Cascallar 4bb12d73d9 fix membership query for avoid project-role without users 2019-01-22 11:29:20 +01:00
Héctor Fernández Cascallar e736846562 code refactor 2019-01-22 11:29:20 +01:00
Héctor Fernández Cascallar 2bdd652ea7 remove pytest development mark 2019-01-22 11:29:20 +01:00
Héctor Fernández Cascallar 483d3ffd5f add tests for task filters 2019-01-22 11:29:20 +01:00
Héctor Fernández Cascallar 2d77f8974b add exclude mode for user stories filter 2019-01-22 11:29:20 +01:00
Héctor Fernández Cascallar 77fa09a953 add exclude mode for issues filters 2019-01-22 11:29:20 +01:00
Álex Hermida df9830bb4f Update change log 2019-01-14 13:48:44 +01:00
Álex Hermida 849ce97a1c Update messages catalog 2019-01-14 13:25:24 +01:00
Álex Hermida c260a4dd22 Upgrade calculate milestone is closed 2019-01-11 13:24:48 +01:00
Álex Hermida 5f301450df Modify create task factory 2019-01-03 22:13:49 +01:00
Álex Hermida abf2b11220 Refactor and fix tests 2019-01-03 22:13:49 +01:00
Álex Hermida a4256c3f09 Add issue milestones signals 2019-01-03 22:13:49 +01:00
Álex Hermida 18e97be27c Add move issues to sprint test & endpoint 2019-01-03 22:13:49 +01:00
Álex Hermida a17ed83755 Move tasks to another sprint 2019-01-03 22:13:49 +01:00
Álex Hermida fe4cddac30 Test move and close oprevious sprint 2019-01-03 22:13:49 +01:00
Álex Hermida 0cb423c929 Change approach move us's from sprint to another 2019-01-03 22:13:49 +01:00
Álex Hermida 39e9de71cf Add milestones bulk items 2019-01-03 22:13:49 +01:00
Álex Hermida 4c74e6182f Add test test_api_update_milestone_in_bulk_userstories 2019-01-03 22:13:49 +01:00
Álex Hermida dae83618a1 Update change log 2018-12-10 19:36:53 +01:00
Miguel Gonzalez 97b69cdb61 Add extra requirements for oauthlib
See https://oauthlib.readthedocs.io/en/latest/faq.html?highlight=signedtoken#oauth-2-serviceapplicationclient-and-oauth-1-with-rsa-sha1-signatures-say-could-not-import-jwt-what-should-i-do
2018-12-10 10:49:58 +01:00
Álex Hermida a5386cb79c Update change log 2018-12-03 21:41:03 +01:00
Álex Hermida 6a0a55f982 Update messages catalog 2018-12-03 21:40:15 +01:00
Miguel Gonzalez 2d91b75096 Update changelog 2018-11-28 08:34:56 +01:00
Álex Hermida 0e1105fb2b Check permissions on webnotifications list 2018-11-28 08:32:33 +01:00
Álex Hermida 49520bcc9b Update change log 2018-11-27 18:27:15 +01:00
Álex Hermida 3cd0da80d5 Update messages catalog 2018-11-27 18:09:47 +01:00
Álex Hermida 4fa4c02d16 Add my_homepage attr to projects list 2018-11-27 18:03:50 +01:00
Daniel García 809a90b777 Added custom fields dropdown, checkbox and number 2018-11-27 18:03:50 +01:00
Álex Hermida 305b8f048f Add tests update invalid milestone in bulk 2018-11-27 18:03:50 +01:00
Álex Hermida ad757f233b Add update issue milestone in bulk 2018-11-27 18:03:50 +01:00
Álex Hermida 8806fce1eb Add test update issue milestone in bulk 2018-11-27 18:03:50 +01:00
Álex Hermida 7d8073f801 Add update tasks milestone in bulk 2018-11-27 18:03:50 +01:00
Álex Hermida 90a12ee95a Add test update tasks milestone in bulk 2018-11-27 18:03:50 +01:00
Álex Hermida 10b5baefbb Add user story bulk update milestone test 2018-11-27 18:03:50 +01:00
Álex Hermida 80002086d5 Fixe timeline query 2018-11-27 18:03:50 +01:00
Daniel García 9b70d25f49 Enable filtering by project in UserProjectSettingsViewSet 2018-11-27 18:03:50 +01:00
Miguel Gonzalez 8e4d1a5653 Pin kombu version to match celery
See https://github.com/celery/kombu/issues/873
2018-11-27 18:03:50 +01:00
Daniel García a35db80932 Set project on top on user projects list 2018-11-27 18:03:50 +01:00
Miguel Gonzalez 533d72fee6 Remove unused file 2018-11-27 18:03:50 +01:00
Miguel Gonzalez 117fc011f7 Update compatibility to python3.5+ 2018-11-27 18:03:50 +01:00
Miguel Gonzalez 963763be31 Adapt test to new bleach version behaviour 2018-11-27 18:03:50 +01:00
Miguel Gonzalez 22eae180c6 Remove md_globals argument
See https://python-markdown.github.io/change_log/release-3.0/#md_globals-keyword-deprecated-from-extension-api
2018-11-27 18:03:50 +01:00
Miguel Gonzalez 6842a7bb21 Configure tox 2018-11-27 18:03:50 +01:00
Miguel Gonzalez 03bf1f0a9a Adjust config 2018-11-27 18:03:50 +01:00
Miguel Gonzalez cb24850466 Update requirements and use pipenv to manage them 2018-11-27 18:03:50 +01:00
Álex Hermida 203e37d98b add attachment new filter to only relevant timeline 2018-11-27 18:03:50 +01:00
Daniel García b47a4d5dad Enable activity pagination 2018-11-27 18:03:50 +01:00
Daniel García 9fd40ce1bc Set default permissions for anon user when importing from Trello 2018-11-27 18:03:50 +01:00
Daniel García 08e31a2ca9 Web notifications 2018-11-27 18:03:50 +01:00
Álex Hermida 4fe58359f4 Refactor tests 2018-11-27 18:03:50 +01:00
Álex Hermida 4b5e58f806 Refactor migrations 2018-11-27 18:03:50 +01:00
Álex Hermida 81ff7fd666 remove extra sections 2018-11-27 18:03:50 +01:00
Álex Hermida fc9af7d1df Remove meetup from homepage section 2018-11-27 18:03:50 +01:00
Álex Hermida 54fe5ba79c Validate user settings homepage 2018-11-27 18:03:50 +01:00
Álex Hermida 5ac2cf6146 Add allowed sections to project settings serializer 2018-11-27 18:03:50 +01:00
Álex Hermida 2d7229e494 Add homepage allowed sections 2018-11-27 18:03:50 +01:00
Álex Hermida f188974f31 Add project settings tests 2018-11-27 18:03:50 +01:00
Álex Hermida 0512b1a47d Add migrations 2018-11-27 18:03:50 +01:00
Daniel García 9389b65157 User settings by project 2018-11-27 18:03:50 +01:00
98 changed files with 8665 additions and 528 deletions

View File

@ -17,8 +17,9 @@ before_install:
- sudo /etc/init.d/postgresql start 9.4
- psql -c 'create database taiga;' -U postgres
install:
- travis_retry pip install -r requirements-devel.txt
- travis_retry pip install pipenv
- travis_retry pipenv sync --dev
script:
- travis_retry coverage run --source=taiga --omit='*tests*,*commands*,*migrations*,*admin*,*.jinja,*dashboard*,*settings*,*wsgi*,*questions*,*documents*' -m py.test -v --tb=native
- travis_retry pipenv run coverage run --source=taiga --omit='*tests*,*commands*,*migrations*,*admin*,*.jinja,*dashboard*,*settings*,*wsgi*,*questions*,*documents*' -m pytest -v --tb=native
after_success:
- coveralls

View File

@ -2,6 +2,60 @@
## Unreleased
## 4.1.0 (2019-02-04)
### Misc
- Fix Close sprints
### Features:
- Negative filters
- Activate the Ukrainian language
## 4.0.4 (2019-01-15)
### Misc
- Minor bug fixes.
## 4.0.3 (2018-12-11)
### Misc
- Add extra requirements for oauthlib
## 4.0.2 (2018-12-04)
### Misc
- Update messages catalog.
## 4.0.1 (2018-11-28)
### Misc
- Minor bug fix.
## 4.0.0 Larix cajanderi (2018-11-28)
### Features
- Custom home section (https://tree.taiga.io/project/taiga/issue/3059)
- Custom fields (https://tree.taiga.io/project/taiga/issue/3725):
- Dropdown
- Checkbox
- Number
- Bulk move unfinished objects in sprint (https://tree.taiga.io/project/taiga/issue/5451)
- Paginate history activity
- Improve notifications area (https://tree.taiga.io/project/taiga/issue/2165 and
https://tree.taiga.io/project/taiga/issue/3752)
### Misc
- Minor icon changes
- Lots of small bugfixes
## 3.4.5 (2018-10-15)
### Features

53
Pipfile Normal file
View File

@ -0,0 +1,53 @@
[[source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"
[packages]
asana = "==0.6.7"
bleach = "==2.1.4"
celery = "==4.0.2"
cryptography = "==2.3.1"
diff-match-patch = "==20121119"
django-ipware = "==1.1.6"
django-jinja = "==2.3.1"
django-picklefield = "==0.3.2"
django-pglocks = "==1.0.2"
django-sampledatahelper = "==0.4.1"
django-sites = "==0.9"
django-sr = "==0.0.4"
djmail = "==1.0.1"
easy-thumbnails = "==2.4.1"
fn = "==0.4.3"
gunicorn = "==19.7.1"
kombu = "==4.0.2" # The same as celery
netaddr = "==0.7.19"
premailer = "==3.0.1"
psd-tools = "==1.4"
"psycopg2-binary" = "==2.7.5"
python-dateutil = "==2.7.5"
python-magic = "==0.4.13"
pytz = "*"
raven = "==6.1.0"
redis = "==2.10.5"
requests = "==2.20.0"
requests_oauthlib = "*"
serpy = "==0.1.1"
webcolors = "==1.7"
CairoSVG = "==2.0.3"
Django = "~=1.11.15"
Markdown = "==3.0.1"
Pillow = "==4.1.1"
Unidecode = "==0.4.20"
Pygments = "==2.2.0"
oauthlib = {extras = ["signedtoken"],version = "*"}
[dev-packages]
coverage = "*"
coveralls = "*"
pytest = "*"
pytest-django = "*"
factory-boy = "*"
[requires]
python_version = "3.6"

805
Pipfile.lock generated Normal file
View File

@ -0,0 +1,805 @@
{
"_meta": {
"hash": {
"sha256": "2505838cdc4b5390130990ae63647a4447c5f146ab37ca8bf29f627091112cf7"
},
"pipfile-spec": 6,
"requires": {
"python_version": "3.6"
},
"sources": [
{
"name": "pypi",
"url": "https://pypi.org/simple",
"verify_ssl": true
}
]
},
"default": {
"amqp": {
"hashes": [
"sha256:073dd02fdd73041bffc913b767866015147b61f2a9bc104daef172fc1a0066eb",
"sha256:eed41946890cd43e8dee44a316b85cf6fee5a1a34bb4a562b660a358eb529e1b"
],
"version": "==2.3.2"
},
"asana": {
"hashes": [
"sha256:412398ff0f72104f5fb602653b994cdd36de89aedff2d37229b237f300f5f01b",
"sha256:d576601116764050c4cf63b417f1c24700b76cf6686f0e51e6b0b77d450e7973"
],
"index": "pypi",
"version": "==0.6.7"
},
"asn1crypto": {
"hashes": [
"sha256:2f1adbb7546ed199e3c90ef23ec95c5cf3585bac7d11fb7eb562a3fe89c64e87",
"sha256:9d5c20441baf0cb60a4ac34cc447c6c189024b6b4c6cd7877034f4965c464e49"
],
"version": "==0.24.0"
},
"billiard": {
"hashes": [
"sha256:42d9a227401ac4fba892918bba0a0c409def5435c4b483267ebfe821afaaba0e"
],
"version": "==3.5.0.5"
},
"bleach": {
"hashes": [
"sha256:0ee95f6167129859c5dce9b1ca291ebdb5d8cd7e382ca0e237dfd0dad63f63d8",
"sha256:24754b9a7d530bf30ce7cbc805bc6cce785660b4a10ff3a43633728438c105ab"
],
"index": "pypi",
"version": "==2.1.4"
},
"cairocffi": {
"hashes": [
"sha256:15386c3a9e08823d6826c4491eaccc7b7254b1dc587a3b9ce60c350c3f990337"
],
"version": "==0.9.0"
},
"cairosvg": {
"hashes": [
"sha256:d2da5aaa31ded26affd5cdffc371ec4cc48800bc2d822a9c28504360482418a1"
],
"index": "pypi",
"version": "==2.0.3"
},
"celery": {
"hashes": [
"sha256:0e5b7e0d7f03aa02061abfd27aa9da05b6740281ca1f5228a54fbf7fe74d8afa",
"sha256:e3d5a6c56a73ff8f2ddd4d06dc37f4c2afe4bb4da7928b884d0725ea865ef54d"
],
"index": "pypi",
"version": "==4.0.2"
},
"certifi": {
"hashes": [
"sha256:47f9c83ef4c0c621eaef743f133f09fa8a74a9b75f037e8624f83bd1b6626cb7",
"sha256:993f830721089fef441cdfeb4b2c8c9df86f0c63239f06bd025a76a7daddb033"
],
"version": "==2018.11.29"
},
"cffi": {
"hashes": [
"sha256:151b7eefd035c56b2b2e1eb9963c90c6302dc15fbd8c1c0a83a163ff2c7d7743",
"sha256:1553d1e99f035ace1c0544050622b7bc963374a00c467edafac50ad7bd276aef",
"sha256:1b0493c091a1898f1136e3f4f991a784437fac3673780ff9de3bcf46c80b6b50",
"sha256:2ba8a45822b7aee805ab49abfe7eec16b90587f7f26df20c71dd89e45a97076f",
"sha256:3bb6bd7266598f318063e584378b8e27c67de998a43362e8fce664c54ee52d30",
"sha256:3c85641778460581c42924384f5e68076d724ceac0f267d66c757f7535069c93",
"sha256:3eb6434197633b7748cea30bf0ba9f66727cdce45117a712b29a443943733257",
"sha256:495c5c2d43bf6cebe0178eb3e88f9c4aa48d8934aa6e3cddb865c058da76756b",
"sha256:4c91af6e967c2015729d3e69c2e51d92f9898c330d6a851bf8f121236f3defd3",
"sha256:57b2533356cb2d8fac1555815929f7f5f14d68ac77b085d2326b571310f34f6e",
"sha256:770f3782b31f50b68627e22f91cb182c48c47c02eb405fd689472aa7b7aa16dc",
"sha256:79f9b6f7c46ae1f8ded75f68cf8ad50e5729ed4d590c74840471fc2823457d04",
"sha256:7a33145e04d44ce95bcd71e522b478d282ad0eafaf34fe1ec5bbd73e662f22b6",
"sha256:857959354ae3a6fa3da6651b966d13b0a8bed6bbc87a0de7b38a549db1d2a359",
"sha256:87f37fe5130574ff76c17cab61e7d2538a16f843bb7bca8ebbc4b12de3078596",
"sha256:95d5251e4b5ca00061f9d9f3d6fe537247e145a8524ae9fd30a2f8fbce993b5b",
"sha256:9d1d3e63a4afdc29bd76ce6aa9d58c771cd1599fbba8cf5057e7860b203710dd",
"sha256:a36c5c154f9d42ec176e6e620cb0dd275744aa1d804786a71ac37dc3661a5e95",
"sha256:a6a5cb8809091ec9ac03edde9304b3ad82ad4466333432b16d78ef40e0cce0d5",
"sha256:ae5e35a2c189d397b91034642cb0eab0e346f776ec2eb44a49a459e6615d6e2e",
"sha256:b0f7d4a3df8f06cf49f9f121bead236e328074de6449866515cea4907bbc63d6",
"sha256:b75110fb114fa366b29a027d0c9be3709579602ae111ff61674d28c93606acca",
"sha256:ba5e697569f84b13640c9e193170e89c13c6244c24400fc57e88724ef610cd31",
"sha256:be2a9b390f77fd7676d80bc3cdc4f8edb940d8c198ed2d8c0be1319018c778e1",
"sha256:ca1bd81f40adc59011f58159e4aa6445fc585a32bb8ac9badf7a2c1aa23822f2",
"sha256:d5d8555d9bfc3f02385c1c37e9f998e2011f0db4f90e250e5bc0c0a85a813085",
"sha256:e55e22ac0a30023426564b1059b035973ec82186ddddbac867078435801c7801",
"sha256:e90f17980e6ab0f3c2f3730e56d1fe9bcba1891eeea58966e89d352492cc74f4",
"sha256:ecbb7b01409e9b782df5ded849c178a0aa7c906cf8c5a67368047daab282b184",
"sha256:ed01918d545a38998bfa5902c7c00e0fee90e957ce036a4000a88e3fe2264917",
"sha256:edabd457cd23a02965166026fd9bfd196f4324fe6032e866d0f3bd0301cd486f",
"sha256:fdf1c1dc5bafc32bc5d08b054f94d659422b05aba244d6be4ddc1c72d9aa70fb"
],
"version": "==1.11.5"
},
"chardet": {
"hashes": [
"sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
"sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"
],
"version": "==3.0.4"
},
"contextlib2": {
"hashes": [
"sha256:509f9419ee91cdd00ba34443217d5ca51f5a364a404e1dce9e8979cea969ca48",
"sha256:f5260a6e679d2ff42ec91ec5252f4eeffdcf21053db9113bd0a8e4d953769c00"
],
"version": "==0.5.5"
},
"cryptography": {
"hashes": [
"sha256:02602e1672b62e803e08617ec286041cc453e8d43f093a5f4162095506bc0beb",
"sha256:10b48e848e1edb93c1d3b797c83c72b4c387ab0eb4330aaa26da8049a6cbede0",
"sha256:17db09db9d7c5de130023657be42689d1a5f60502a14f6f745f6f65a6b8195c0",
"sha256:227da3a896df1106b1a69b1e319dce218fa04395e8cc78be7e31ca94c21254bc",
"sha256:2cbaa03ac677db6c821dac3f4cdfd1461a32d0615847eedbb0df54bb7802e1f7",
"sha256:31db8febfc768e4b4bd826750a70c79c99ea423f4697d1dab764eb9f9f849519",
"sha256:4a510d268e55e2e067715d728e4ca6cd26a8e9f1f3d174faf88e6f2cb6b6c395",
"sha256:6a88d9004310a198c474d8a822ee96a6dd6c01efe66facdf17cb692512ae5bc0",
"sha256:76936ec70a9b72eb8c58314c38c55a0336a2b36de0c7ee8fb874a4547cadbd39",
"sha256:7e3b4aecc4040928efa8a7cdaf074e868af32c58ffc9bb77e7bf2c1a16783286",
"sha256:8168bcb08403ef144ff1fb880d416f49e2728101d02aaadfe9645883222c0aa5",
"sha256:8229ceb79a1792823d87779959184a1bf95768e9248c93ae9f97c7a2f60376a1",
"sha256:8a19e9f2fe69f6a44a5c156968d9fc8df56d09798d0c6a34ccc373bb186cee86",
"sha256:8d10113ca826a4c29d5b85b2c4e045ffa8bad74fb525ee0eceb1d38d4c70dfd6",
"sha256:be495b8ec5a939a7605274b6e59fbc35e76f5ad814ae010eb679529671c9e119",
"sha256:dc2d3f3b1548f4d11786616cf0f4415e25b0fbecb8a1d2cd8c07568f13fdde38",
"sha256:e4aecdd9d5a3d06c337894c9a6e2961898d3f64fe54ca920a72234a3de0f9cb3",
"sha256:e79ab4485b99eacb2166f3212218dd858258f374855e1568f728462b0e6ee0d9",
"sha256:f995d3667301e1754c57b04e0bae6f0fa9d710697a9f8d6712e8cca02550910f"
],
"index": "pypi",
"version": "==2.3.1"
},
"cssselect": {
"hashes": [
"sha256:066d8bc5229af09617e24b3ca4d52f1f9092d9e061931f4184cd572885c23204",
"sha256:3b5103e8789da9e936a68d993b70df732d06b8bb9a337a05ed4eb52c17ef7206"
],
"version": "==1.0.3"
},
"cssutils": {
"hashes": [
"sha256:a2fcf06467553038e98fea9cfe36af2bf14063eb147a70958cfcaa8f5786acaf",
"sha256:c74dbe19c92f5052774eadb15136263548dd013250f1ed1027988e7fef125c8d"
],
"version": "==1.0.2"
},
"diff-match-patch": {
"hashes": [
"sha256:9dba5611fbf27893347349fd51cc1911cb403682a7163373adacc565d11e2e4c"
],
"index": "pypi",
"version": "==20121119"
},
"django": {
"hashes": [
"sha256:29268cc47816a44f27308e60f71da635f549c47d8a1d003b28de55141df75791",
"sha256:37f5876c1fbfd66085001f4c06fa0bf96ef05442c53daf8d4294b6f29e7fa6b8"
],
"index": "pypi",
"version": "==1.11.16"
},
"django-ipware": {
"hashes": [
"sha256:93a90f9dd8caf2c633172aa8c8ba4e76e2b44f92a6942fa35e7624281e81ea03"
],
"index": "pypi",
"version": "==1.1.6"
},
"django-jinja": {
"hashes": [
"sha256:5e826a0cce967f40e6fdc037ea23667a2d3cd072807c4c87ffcc010b3c59121f",
"sha256:ebfde44cb716e57a9cdff6c1a4935fc49c7419ea4cd0b2b89bcecc696b9c0c86"
],
"index": "pypi",
"version": "==2.3.1"
},
"django-pglocks": {
"hashes": [
"sha256:9405ee54bbf157bb16b814f20ea7ad9d82d5cf26f9bf3ea8e3a71032179844cf"
],
"index": "pypi",
"version": "==1.0.2"
},
"django-picklefield": {
"hashes": [
"sha256:5489fef164de43725242d56e65e016137d3df0d1a00672bda72d807f5b2b0d99",
"sha256:fab48a427c6310740755b242128f9300283bef159ffee42d3231a274c65d9ae2"
],
"index": "pypi",
"version": "==0.3.2"
},
"django-sampledatahelper": {
"hashes": [
"sha256:96d0a599054979eb9669d44a1735236da42d56be0c45d4bcd34c2a3acefb259d"
],
"index": "pypi",
"version": "==0.4.1"
},
"django-sites": {
"hashes": [
"sha256:fa1470bd72be589f3891b7cd28dd2d782d8b34539665d9334e49154566f3d916"
],
"index": "pypi",
"version": "==0.9"
},
"django-sr": {
"hashes": [
"sha256:3586b852ae8af1b4b2796590534b0b867b523f47a5779b2ccb6ce010efc57e34"
],
"index": "pypi",
"version": "==0.0.4"
},
"djmail": {
"hashes": [
"sha256:370f57230bf79004840ddd1cb8150a5b7676b2aa14bc5b62027106cb708a47a0"
],
"index": "pypi",
"version": "==1.0.1"
},
"docopt": {
"hashes": [
"sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"
],
"version": "==0.6.2"
},
"easy-thumbnails": {
"hashes": [
"sha256:5cc51c6ec7de110355d0f8cd56c9ede6e2949e87c2fcb34bc864a20ecd424270",
"sha256:6e41e70a182a6d00af9f3f3a6d1cc87cb7da060a3d56982da51d266e40fc9b59"
],
"index": "pypi",
"version": "==2.4.1"
},
"fn": {
"hashes": [
"sha256:f8cd80cdabf15367a2f07e7a9951fdc013d7200412743d85b88f2c896c95bada"
],
"index": "pypi",
"version": "==0.4.3"
},
"gunicorn": {
"hashes": [
"sha256:75af03c99389535f218cc596c7de74df4763803f7b63eb09d77e92b3956b36c6",
"sha256:eee1169f0ca667be05db3351a0960765620dad53f53434262ff8901b68a1b622"
],
"index": "pypi",
"version": "==19.7.1"
},
"html5lib": {
"hashes": [
"sha256:20b159aa3badc9d5ee8f5c647e5efd02ed2a66ab8d354930bd9ff139fc1dc0a3",
"sha256:66cb0dcfdbbc4f9c3ba1a63fdb511ffdbd4f513b2b6d81b80cd26ce6b3fb3736"
],
"version": "==1.0.1"
},
"idna": {
"hashes": [
"sha256:156a6814fb5ac1fc6850fb002e0852d56c0c8d2531923a51032d1b70760e186e",
"sha256:684a38a6f903c1d71d6d5fac066b58d7768af4de2b832e426ec79c30daa94a16"
],
"version": "==2.7"
},
"jinja2": {
"hashes": [
"sha256:74c935a1b8bb9a3947c50a54766a969d4846290e1e788ea44c1392163723c3bd",
"sha256:f84be1bb0040caca4cea721fcbbbbd61f9be9464ca236387158b0feea01914a4"
],
"version": "==2.10"
},
"kombu": {
"hashes": [
"sha256:385bf38e6de7f3851f674671dbfe24572ce999608d293a85fb8a630654d8bd9c",
"sha256:d0fc6f2a36610a308f838db4b832dad79a681b516ac1d1a1f9d42edb58cc11a2"
],
"index": "pypi",
"version": "==4.0.2"
},
"lxml": {
"hashes": [
"sha256:02bc220d61f46e9b9d5a53c361ef95e9f5e1d27171cd461dddb17677ae2289a5",
"sha256:22f253b542a342755f6cfc047fe4d3a296515cf9b542bc6e261af45a80b8caf6",
"sha256:2f31145c7ff665b330919bfa44aacd3a0211a76ca7e7b441039d2a0b0451e415",
"sha256:36720698c29e7a9626a0dc802ef8885f8f0239bfd1689628ecd459a061f2807f",
"sha256:438a1b0203545521f6616132bfe0f4bca86f8a401364008b30e2b26ec408ce85",
"sha256:4815892904c336bbaf73dafd54f45f69f4021c22b5bad7332176bbf4fb830568",
"sha256:5be031b0f15ad63910d8e5038b489d95a79929513b3634ad4babf77100602588",
"sha256:5c93ae37c3c588e829b037fdfbd64a6e40c901d3f93f7beed6d724c44829a3ad",
"sha256:60842230678674cdac4a1cf0f707ef12d75b9a4fc4a565add4f710b5fcf185d5",
"sha256:62939a8bb6758d1bf923aa1c13f0bcfa9bf5b2fc0f5fa917a6e25db5fe0cfa4e",
"sha256:75830c06a62fe7b8fe3bbb5f269f0b308f19f3949ac81cfd40062f47c1455faf",
"sha256:81992565b74332c7c1aff6a913a3e906771aa81c9d0c68c68113cffcae45bc53",
"sha256:8c892fb0ee52c594d9a7751c7d7356056a9682674b92cc1c4dc968ff0f30c52f",
"sha256:9d862e3cf4fc1f2837dedce9c42269c8c76d027e49820a548ac89fdcee1e361f",
"sha256:a623965c086a6e91bb703d4da62dabe59fe88888e82c4117d544e11fd74835d6",
"sha256:a7783ab7f6a508b0510490cef9f857b763d796ba7476d9703f89722928d1e113",
"sha256:aab09fbe8abfa3b9ce62aaf45aca2d28726b1b9ee44871dbe644050a2fff4940",
"sha256:abf181934ac3ef193832fb973fd7f6149b5c531903c2ec0f1220941d73eee601",
"sha256:ae07fa0c115733fce1e9da96a3ac3fa24801742ca17e917e0c79d63a01eeb843",
"sha256:b9c78242219f674ab645ec571c9a95d70f381319a23911941cd2358a8e0521cf",
"sha256:bccb267678b870d9782c3b44d0cefe3ba0e329f9af8c946d32bf3778e7a4f271",
"sha256:c4df4d27f4c93b2cef74579f00b1d3a31a929c7d8023f870c4b476f03a274db4",
"sha256:caf0e50b546bb60dfa99bb18dfa6748458a83131ecdceaf5c071d74907e7e78a",
"sha256:d3266bd3ac59ac4edcd5fa75165dee80b94a3e5c91049df5f7c057ccf097551c",
"sha256:db0d213987bcd4e6d41710fb4532b22315b0d8fb439ff901782234456556aed1",
"sha256:dbbd5cf7690a40a9f0a9325ab480d0fccf46d16b378eefc08e195d84299bfae1",
"sha256:e16e07a0ec3a75b5ee61f2b1003c35696738f937dc8148fbda9fe2147ccb6e61",
"sha256:e175a006725c7faadbe69e791877d09936c0ef2cf49d01b60a6c1efcb0e8be6f",
"sha256:edd9c13a97f6550f9da2236126bb51c092b3b1ce6187f2bd966533ad794bbb5e",
"sha256:fa39ea60d527fbdd94215b5e5552f1c6a912624521093f1384a491a8ad89ad8b"
],
"version": "==4.2.5"
},
"markdown": {
"hashes": [
"sha256:c00429bd503a47ec88d5e30a751e147dcb4c6889663cd3e2ba0afe858e009baa",
"sha256:d02e0f9b04c500cde6637c11ad7c72671f359b87b9fe924b2383649d8841db7c"
],
"index": "pypi",
"version": "==3.0.1"
},
"markupsafe": {
"hashes": [
"sha256:048ef924c1623740e70204aa7143ec592504045ae4429b59c30054cb31e3c432",
"sha256:130f844e7f5bdd8e9f3f42e7102ef1d49b2e6fdf0d7526df3f87281a532d8c8b",
"sha256:19f637c2ac5ae9da8bfd98cef74d64b7e1bb8a63038a3505cd182c3fac5eb4d9",
"sha256:1b8a7a87ad1b92bd887568ce54b23565f3fd7018c4180136e1cf412b405a47af",
"sha256:1c25694ca680b6919de53a4bb3bdd0602beafc63ff001fea2f2fc16ec3a11834",
"sha256:1f19ef5d3908110e1e891deefb5586aae1b49a7440db952454b4e281b41620cd",
"sha256:1fa6058938190ebe8290e5cae6c351e14e7bb44505c4a7624555ce57fbbeba0d",
"sha256:31cbb1359e8c25f9f48e156e59e2eaad51cd5242c05ed18a8de6dbe85184e4b7",
"sha256:3e835d8841ae7863f64e40e19477f7eb398674da6a47f09871673742531e6f4b",
"sha256:4e97332c9ce444b0c2c38dd22ddc61c743eb208d916e4265a2a3b575bdccb1d3",
"sha256:525396ee324ee2da82919f2ee9c9e73b012f23e7640131dd1b53a90206a0f09c",
"sha256:52b07fbc32032c21ad4ab060fec137b76eb804c4b9a1c7c7dc562549306afad2",
"sha256:52ccb45e77a1085ec5461cde794e1aa037df79f473cbc69b974e73940655c8d7",
"sha256:5c3fbebd7de20ce93103cb3183b47671f2885307df4a17a0ad56a1dd51273d36",
"sha256:5e5851969aea17660e55f6a3be00037a25b96a9b44d2083651812c99d53b14d1",
"sha256:5edfa27b2d3eefa2210fb2f5d539fbed81722b49f083b2c6566455eb7422fd7e",
"sha256:7d263e5770efddf465a9e31b78362d84d015cc894ca2c131901a4445eaa61ee1",
"sha256:83381342bfc22b3c8c06f2dd93a505413888694302de25add756254beee8449c",
"sha256:857eebb2c1dc60e4219ec8e98dfa19553dae33608237e107db9c6078b1167856",
"sha256:98e439297f78fca3a6169fd330fbe88d78b3bb72f967ad9961bcac0d7fdd1550",
"sha256:bf54103892a83c64db58125b3f2a43df6d2cb2d28889f14c78519394feb41492",
"sha256:d9ac82be533394d341b41d78aca7ed0e0f4ba5a2231602e2f05aa87f25c51672",
"sha256:e982fe07ede9fada6ff6705af70514a52beb1b2c3d25d4e873e82114cf3c5401",
"sha256:edce2ea7f3dfc981c4ddc97add8a61381d9642dc3273737e756517cc03e84dd6",
"sha256:efdc45ef1afc238db84cb4963aa689c0408912a0239b0721cb172b4016eb31d6",
"sha256:f137c02498f8b935892d5c0172560d7ab54bc45039de8805075e19079c639a9c",
"sha256:f82e347a72f955b7017a39708a3667f106e6ad4d10b25f237396a7115d8ed5fd",
"sha256:fb7c206e01ad85ce57feeaaa0bf784b97fa3cad0d4a5737bc5295785f5c613a1"
],
"version": "==1.1.0"
},
"netaddr": {
"hashes": [
"sha256:38aeec7cdd035081d3a4c306394b19d677623bf76fa0913f6695127c7753aefd",
"sha256:56b3558bd71f3f6999e4c52e349f38660e54a7a8a9943335f73dfc96883e08ca"
],
"index": "pypi",
"version": "==0.7.19"
},
"oauthlib": {
"hashes": [
"sha256:ac35665a61c1685c56336bda97d5eefa246f1202618a1d6f34fccb1bdd404162",
"sha256:d883b36b21a6ad813953803edfa563b1b579d79ca758fe950d1bc9e8b326025b"
],
"version": "==2.1.0"
},
"olefile": {
"hashes": [
"sha256:133b031eaf8fd2c9399b78b8bc5b8fcbe4c31e85295749bb17a87cba8f3c3964"
],
"version": "==0.46"
},
"pillow": {
"hashes": [
"sha256:00b6a5f28d00f720235a937ebc2f50f4292a5c7e2d6ab9a8b26153b625c4f431",
"sha256:025208f835383f425e93d574842f9c5d28918cd4cdf632c1ce2e72ab80d8fcc8",
"sha256:059a9b4e064b70e1396a3ae64781a91512f773cae548c24b12014616f723f22d",
"sha256:17f7702f22729ffeb69f5226abf3261ef2d2eb73ab5854c1294daa3bdb5bdfb7",
"sha256:20a3549d7a83e969eb900c726d54b34673efc1d0e3c9856b8227350f7e21d968",
"sha256:24258e1875c8a9de1b176bf1873436397669440d1561b06b00eb270bffefcb42",
"sha256:318b4404c8ca34cc1514d60de81ac4a0b0d11031a70341c2b7cd4fc01c914d89",
"sha256:33a71986741227c8c085ee5929171cd4c9376b2eee189cdc7acc7d81e1a3ca84",
"sha256:3499deb97561d6cc75de725fcf744491dd2d10d6213a29c4d62e3980e1522715",
"sha256:3ad24690882b68599b9e6b25309000881eebcc731ca499f8dcc549fab006e4a3",
"sha256:3c05df947656d8538dfb39fb8ddb9fe3594c9345911aa19f07e2ed0a8d148a6d",
"sha256:48cdae5e5291d355fc215ede2ea93738e243c2467b11e41fa5010a76fd278fc5",
"sha256:4b382c0ee6ac822673e1a57c2e9878d2ac4cd52038be097bac8535a2ee60ec0e",
"sha256:6458293cf299f02f17f58a1ee4b91f77b8ce7a38bc0e757838767f1389479953",
"sha256:8ef6627adfe9314b4132d4f5207563ba147e3977019ab1ca3f0b11b04c83c84f",
"sha256:9c508bf0b2aadad4349f69aebe080977dbf0cd055cefc15793c4165851a96933",
"sha256:9d7c0706cd86fc17643d78674cdac7f05590a2da1d71c42c2ebfb27df3889f17",
"sha256:a2a873b54881c4cf4d8b37f3c426c2b8f797b341e3893650763a62252bda3922",
"sha256:b79fc81352a3c907a1223499d790a4a7b77be342b794e19628046c4c95676356",
"sha256:c040a047209edaf860ce6dd5b55de718e047144b26b0ee4198dd19907c128eac",
"sha256:d71992826cfed66f5ad68364b2c6c3c1ab305294a642deae96ad77004981fb0f",
"sha256:e467d0977997b43ca80b7af42de3d1cfa779988f6507965e6d4fb1a004e963a0",
"sha256:ecf810b5019ce62846a9203bab82eb02bb9ed60e258b2fd89e2ca19a7010da46",
"sha256:f4fb801bfd2bcbfc4a7f2819c95ea6a1cfef197420ae9849b01b08b9970a51b3",
"sha256:f63404731fa5fa0c21d00af119b867e30208e3fc148c9b13fb6a541a8df203b2",
"sha256:f8e8f3f20e32f73f81ec408061f7a81ada07d7b3fac0787bdd233b93e4ff7d9c",
"sha256:fb3eaba16b6cf01f12860edccac40f98362bc17225575f3bcabb333d0b4ed6dc"
],
"index": "pypi",
"version": "==4.1.1"
},
"premailer": {
"hashes": [
"sha256:25c97c4c1838a8045ed1da1a14bd82ce458687878aa162378a78aa82a6aec6af",
"sha256:4e71cc09ad1438f827d1070ffac54ceb3a6a07c995fa82cb34c1ef163adeb432"
],
"index": "pypi",
"version": "==3.0.1"
},
"psd-tools": {
"hashes": [
"sha256:1a8dd69783c84217f3a25938916c289e5653543c5610e9013769520e8bd65b3c",
"sha256:7f3fd8577fb627405a204a65d4ccea48299f95338a71fbd9709bdb9d7789cc4e",
"sha256:f519a08049c19b3ef37ccc2b39af11ac5ded59d2674fc4c28103e4db57a9aa99"
],
"index": "pypi",
"version": "==1.4"
},
"psycopg2-binary": {
"hashes": [
"sha256:04afb59bbbd2eab3148e6816beddc74348078b8c02a1113ea7f7822f5be4afe3",
"sha256:098b18f4d8857a8f9b206d1dc54db56c2255d5d26458917e7bcad61ebfe4338f",
"sha256:0bf855d4a7083e20ead961fda4923887094eaeace0ab2d76eb4aa300f4bbf5bd",
"sha256:197dda3ffd02057820be83fe4d84529ea70bf39a9a4daee1d20ffc74eb3d042e",
"sha256:278ef63afb4b3d842b4609f2c05ffbfb76795cf6a184deeb8707cd5ed3c981a5",
"sha256:3cbf8c4fc8f22f0817220891cf405831559f4d4c12c4f73913730a2ea6c47a47",
"sha256:4305aed922c4d9d6163ab3a41d80b5a1cfab54917467da8168552c42cad84d32",
"sha256:47ee296f704fb8b2a616dec691cdcfd5fa0f11943955e88faa98cbd1dc3b3e3d",
"sha256:4a0e38cb30457e70580903367161173d4a7d1381eb2f2cfe4e69b7806623f484",
"sha256:4d6c294c6638a71cafb82a37f182f24321f1163b08b5d5ca076e11fe838a3086",
"sha256:4f3233c366500730f839f92833194fd8f9a5c4529c8cd8040aa162c3740de8e5",
"sha256:5221f5a3f4ca2ddf0d58e8b8a32ca50948be9a43351fda797eb4e72d7a7aa34d",
"sha256:5c6ca0b507540a11eaf9e77dee4f07c131c2ec80ca0cffa146671bf690bc1c02",
"sha256:789bd89d71d704db2b3d5e67d6d518b158985d791d3b2dec5ab85457cfc9677b",
"sha256:7b94d29239efeaa6a967f3b5971bd0518d2a24edd1511edbf4a2c8b815220d07",
"sha256:89bc65ef3301c74cf32db25334421ea6adbe8f65601ea45dcaaf095abed910bb",
"sha256:89d6d3a549f405c20c9ae4dc94d7ed2de2fa77427a470674490a622070732e62",
"sha256:97521704ac7127d7d8ba22877da3c7bf4a40366587d238ec679ff38e33177498",
"sha256:a395b62d5f44ff6f633231abe568e2203b8fabf9797cd6386aa92497df912d9a",
"sha256:a6d32c37f714c3f34158f3fa659f3a8f2658d5f53c4297d45579b9677cc4d852",
"sha256:a89ee5c26f72f2d0d74b991ce49e42ddeb4ac0dc2d8c06a0f2770a1ab48f4fe0",
"sha256:b4c8b0ef3608e59317bfc501df84a61e48b5445d45f24d0391a24802de5f2d84",
"sha256:b5fcf07140219a1f71e18486b8dc28e2e1b76a441c19374805c617aa6d9a9d55",
"sha256:b86f527f00956ecebad6ab3bb30e3a75fedf1160a8716978dd8ce7adddedd86f",
"sha256:be4c4aa22ba22f70de36c98b06480e2f1697972d49eb20d525f400d204a6d272",
"sha256:c2ac7aa1a144d4e0e613ac7286dae85671e99fe7a1353954d4905629c36b811c",
"sha256:de26ef4787b5e778e8223913a3e50368b44e7480f83c76df1f51d23bd21cea16",
"sha256:e70ebcfc5372dc7b699c0110454fc4263967f30c55454397e5769eb72c0eb0ce",
"sha256:eadbd32b6bc48b67b0457fccc94c86f7ccc8178ab839f684eb285bb592dc143e",
"sha256:ecbc6dfff6db06b8b72ae8a2f25ff20fbdcb83cb543811a08f7cb555042aa729"
],
"index": "pypi",
"version": "==2.7.5"
},
"pycparser": {
"hashes": [
"sha256:a988718abfad80b6b157acce7bf130a30876d27603738ac39f140993246b25b3"
],
"version": "==2.19"
},
"pygments": {
"hashes": [
"sha256:78f3f434bcc5d6ee09020f92ba487f95ba50f1e3ef83ae96b9d5ffa1bab25c5d",
"sha256:dbae1046def0efb574852fab9e90209b23f556367b5a320c0bcb871c77c3e8cc"
],
"index": "pypi",
"version": "==2.2.0"
},
"pyjwt": {
"hashes": [
"sha256:00414bfef802aaecd8cc0d5258b6cb87bd8f553c2986c2c5f29b19dd5633aeb7",
"sha256:ddec8409c57e9d371c6006e388f91daf3b0b43bdf9fcbf99451fb7cf5ce0a86d"
],
"version": "==1.7.0"
},
"python-dateutil": {
"hashes": [
"sha256:063df5763652e21de43de7d9e00ccf239f953a832941e37be541614732cdfc93",
"sha256:88f9287c0174266bb0d8cedd395cfba9c58e87e5ad86b2ce58859bc11be3cf02"
],
"index": "pypi",
"version": "==2.7.5"
},
"python-magic": {
"hashes": [
"sha256:604eace6f665809bebbb07070508dfa8cabb2d7cb05be9a56706c60f864f1289",
"sha256:a1ec69e76cc513b1af164c02982607f96ff3bb668162a688f2b1bb5f6a5fe05d"
],
"index": "pypi",
"version": "==0.4.13"
},
"pytz": {
"hashes": [
"sha256:31cb35c89bd7d333cd32c5f278fca91b523b0834369e757f4c5641ea252236ca",
"sha256:8e0f8568c118d3077b46be7d654cc8167fa916092e28320cde048e54bfc9f1e6"
],
"index": "pypi",
"version": "==2018.7"
},
"raven": {
"hashes": [
"sha256:02cabffb173b99d860a95d4908e8b1864aad1b8452146e13fd7e212aa576a884",
"sha256:56dc9062dd42bca97350e5048ff417c914376366caa3b1b5f788b27ddc0a34b7"
],
"index": "pypi",
"version": "==6.1.0"
},
"redis": {
"hashes": [
"sha256:5dfbae6acfc54edf0a7a415b99e0b21c0a3c27a7f787b292eea727b1facc5533",
"sha256:97156b37d7cda4e7d8658be1148c983984e1a975090ba458cc7e244025191dbd"
],
"index": "pypi",
"version": "==2.10.5"
},
"requests": {
"hashes": [
"sha256:99dcfdaaeb17caf6e526f32b6a7b780461512ab3f1d992187801694cba42770c",
"sha256:a84b8c9ab6239b578f22d1c21d51b696dcfe004032bb80ea832398d6909d7279"
],
"index": "pypi",
"version": "==2.20.0"
},
"requests-oauthlib": {
"hashes": [
"sha256:50a8ae2ce8273e384895972b56193c7409601a66d4975774c60c2aed869639ca",
"sha256:883ac416757eada6d3d07054ec7092ac21c7f35cb1d2cf82faf205637081f468"
],
"version": "==0.8.0"
},
"sampledata": {
"hashes": [
"sha256:b06916ef010d3c1a9db8aa314a144f24ad421f28597ff8c568603c451391a2cf"
],
"version": "==0.3.7"
},
"serpy": {
"hashes": [
"sha256:b1481f8cb93d767b23903d1df6cc0a7120cb0694095b6695eb78d9d453b23c65",
"sha256:b774bfdf0c3b245660639e9fc5f311a8bbceb2725aaba72fce1fec00b453286e"
],
"index": "pypi",
"version": "==0.1.1"
},
"six": {
"hashes": [
"sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9",
"sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb"
],
"version": "==1.11.0"
},
"tinycss": {
"hashes": [
"sha256:12306fb50e5e9e7eaeef84b802ed877488ba80e35c672867f548c0924a76716e"
],
"version": "==0.4"
},
"unidecode": {
"hashes": [
"sha256:ed4418b4b1b190487753f1cca6299e8076079258647284414e6d607d1f8a00e0",
"sha256:eedac7bfd886f43484787206f6a141b232e2b2a58652c54d06499b187fd84660"
],
"index": "pypi",
"version": "==0.4.20"
},
"urllib3": {
"hashes": [
"sha256:61bf29cada3fc2fbefad4fdf059ea4bd1b4a86d2b6d15e1c7c0b582b9752fe39",
"sha256:de9529817c93f27c8ccbfead6985011db27bd0ddfcdb2d86f3f663385c6a9c22"
],
"version": "==1.24.1"
},
"vine": {
"hashes": [
"sha256:52116d59bc45392af9fdd3b75ed98ae48a93e822cee21e5fda249105c59a7a72",
"sha256:6849544be74ec3638e84d90bc1cf2e1e9224cc10d96cd4383ec3f69e9bce077b"
],
"version": "==1.1.4"
},
"webcolors": {
"hashes": [
"sha256:e47e68644d41c0b1f1e4d939cfe4039bdf1ab31234df63c7a4f59d4766487206"
],
"index": "pypi",
"version": "==1.7"
},
"webencodings": {
"hashes": [
"sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78",
"sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923"
],
"version": "==0.5.1"
}
},
"develop": {
"atomicwrites": {
"hashes": [
"sha256:0312ad34fcad8fac3704d441f7b317e50af620823353ec657a53e981f92920c0",
"sha256:ec9ae8adaae229e4f8446952d204a3e4b5fdd2d099f9be3aaf556120135fb3ee"
],
"version": "==1.2.1"
},
"attrs": {
"hashes": [
"sha256:10cbf6e27dbce8c30807caf056c8eb50917e0eaafe86347671b57254006c3e69",
"sha256:ca4be454458f9dec299268d472aaa5a11f67a4ff70093396e1ceae9c76cf4bbb"
],
"version": "==18.2.0"
},
"certifi": {
"hashes": [
"sha256:47f9c83ef4c0c621eaef743f133f09fa8a74a9b75f037e8624f83bd1b6626cb7",
"sha256:993f830721089fef441cdfeb4b2c8c9df86f0c63239f06bd025a76a7daddb033"
],
"version": "==2018.11.29"
},
"chardet": {
"hashes": [
"sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
"sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"
],
"version": "==3.0.4"
},
"coverage": {
"hashes": [
"sha256:09e47c529ff77bf042ecfe858fb55c3e3eb97aac2c87f0349ab5a7efd6b3939f",
"sha256:0a1f9b0eb3aa15c990c328535655847b3420231af299386cfe5efc98f9c250fe",
"sha256:0cc941b37b8c2ececfed341444a456912e740ecf515d560de58b9a76562d966d",
"sha256:10e8af18d1315de936d67775d3a814cc81d0747a1a0312d84e27ae5610e313b0",
"sha256:1b4276550b86caa60606bd3572b52769860a81a70754a54acc8ba789ce74d607",
"sha256:1e8a2627c48266c7b813975335cfdea58c706fe36f607c97d9392e61502dc79d",
"sha256:2b224052bfd801beb7478b03e8a66f3f25ea56ea488922e98903914ac9ac930b",
"sha256:447c450a093766744ab53bf1e7063ec82866f27bcb4f4c907da25ad293bba7e3",
"sha256:46101fc20c6f6568561cdd15a54018bb42980954b79aa46da8ae6f008066a30e",
"sha256:4710dc676bb4b779c4361b54eb308bc84d64a2fa3d78e5f7228921eccce5d815",
"sha256:510986f9a280cd05189b42eee2b69fecdf5bf9651d4cd315ea21d24a964a3c36",
"sha256:5535dda5739257effef56e49a1c51c71f1d37a6e5607bb25a5eee507c59580d1",
"sha256:5a7524042014642b39b1fcae85fb37556c200e64ec90824ae9ecf7b667ccfc14",
"sha256:5f55028169ef85e1fa8e4b8b1b91c0b3b0fa3297c4fb22990d46ff01d22c2d6c",
"sha256:6694d5573e7790a0e8d3d177d7a416ca5f5c150742ee703f3c18df76260de794",
"sha256:6831e1ac20ac52634da606b658b0b2712d26984999c9d93f0c6e59fe62ca741b",
"sha256:77f0d9fa5e10d03aa4528436e33423bfa3718b86c646615f04616294c935f840",
"sha256:828ad813c7cdc2e71dcf141912c685bfe4b548c0e6d9540db6418b807c345ddd",
"sha256:85a06c61598b14b015d4df233d249cd5abfa61084ef5b9f64a48e997fd829a82",
"sha256:8cb4febad0f0b26c6f62e1628f2053954ad2c555d67660f28dfb1b0496711952",
"sha256:a5c58664b23b248b16b96253880b2868fb34358911400a7ba39d7f6399935389",
"sha256:aaa0f296e503cda4bc07566f592cd7a28779d433f3a23c48082af425d6d5a78f",
"sha256:ab235d9fe64833f12d1334d29b558aacedfbca2356dfb9691f2d0d38a8a7bfb4",
"sha256:b3b0c8f660fae65eac74fbf003f3103769b90012ae7a460863010539bb7a80da",
"sha256:bab8e6d510d2ea0f1d14f12642e3f35cefa47a9b2e4c7cea1852b52bc9c49647",
"sha256:c45297bbdbc8bb79b02cf41417d63352b70bcb76f1bbb1ee7d47b3e89e42f95d",
"sha256:d19bca47c8a01b92640c614a9147b081a1974f69168ecd494687c827109e8f42",
"sha256:d64b4340a0c488a9e79b66ec9f9d77d02b99b772c8b8afd46c1294c1d39ca478",
"sha256:da969da069a82bbb5300b59161d8d7c8d423bc4ccd3b410a9b4d8932aeefc14b",
"sha256:ed02c7539705696ecb7dc9d476d861f3904a8d2b7e894bd418994920935d36bb",
"sha256:ee5b8abc35b549012e03a7b1e86c09491457dba6c94112a2482b18589cc2bdb9"
],
"index": "pypi",
"version": "==4.5.2"
},
"coveralls": {
"hashes": [
"sha256:ab638e88d38916a6cedbf80a9cd8992d5fa55c77ab755e262e00b36792b7cd6d",
"sha256:b2388747e2529fa4c669fb1e3e2756e4e07b6ee56c7d9fce05f35ccccc913aa0"
],
"index": "pypi",
"version": "==1.5.1"
},
"docopt": {
"hashes": [
"sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"
],
"version": "==0.6.2"
},
"factory-boy": {
"hashes": [
"sha256:6f25cc4761ac109efd503f096e2ad99421b1159f01a29dbb917359dcd68e08ca",
"sha256:d552cb872b310ae78bd7429bf318e42e1e903b1a109e899a523293dfa762ea4f"
],
"index": "pypi",
"version": "==2.11.1"
},
"faker": {
"hashes": [
"sha256:c61a41d0dab8865b850bd00454fb11e90f3fd2a092d8bc90120d1e1c01cff906",
"sha256:f909ff9133ce0625ca388b6838190630ad7a593f87eaf058d872338a76241d5d"
],
"version": "==1.0.0"
},
"idna": {
"hashes": [
"sha256:156a6814fb5ac1fc6850fb002e0852d56c0c8d2531923a51032d1b70760e186e",
"sha256:684a38a6f903c1d71d6d5fac066b58d7768af4de2b832e426ec79c30daa94a16"
],
"version": "==2.7"
},
"more-itertools": {
"hashes": [
"sha256:c187a73da93e7a8acc0001572aebc7e3c69daf7bf6881a2cea10650bd4420092",
"sha256:c476b5d3a34e12d40130bc2f935028b5f636df8f372dc2c1c01dc19681b2039e",
"sha256:fcbfeaea0be121980e15bc97b3817b5202ca73d0eae185b4550cbfce2a3ebb3d"
],
"version": "==4.3.0"
},
"pluggy": {
"hashes": [
"sha256:447ba94990e8014ee25ec853339faf7b0fc8050cdc3289d4d71f7f410fb90095",
"sha256:bde19360a8ec4dfd8a20dcb811780a30998101f078fc7ded6162f0076f50508f"
],
"version": "==0.8.0"
},
"py": {
"hashes": [
"sha256:bf92637198836372b520efcba9e020c330123be8ce527e535d185ed4b6f45694",
"sha256:e76826342cefe3c3d5f7e8ee4316b80d1dd8a300781612ddbc765c17ba25a6c6"
],
"version": "==1.7.0"
},
"pytest": {
"hashes": [
"sha256:3f193df1cfe1d1609d4c583838bea3d532b18d6160fd3f55c9447fdca30848ec",
"sha256:e246cf173c01169b9617fc07264b7b1316e78d7a650055235d6d897bc80d9660"
],
"index": "pypi",
"version": "==3.10.1"
},
"pytest-django": {
"hashes": [
"sha256:49e9ffc856bc6a1bec1c26c5c7b7213dff7cc8bc6b64d624c4d143d04aff0bcf",
"sha256:b379282feaf89069cb790775ab6bbbd2bd2038a68c7ef9b84a41898e0b551081"
],
"index": "pypi",
"version": "==3.4.3"
},
"python-dateutil": {
"hashes": [
"sha256:063df5763652e21de43de7d9e00ccf239f953a832941e37be541614732cdfc93",
"sha256:88f9287c0174266bb0d8cedd395cfba9c58e87e5ad86b2ce58859bc11be3cf02"
],
"index": "pypi",
"version": "==2.7.5"
},
"requests": {
"hashes": [
"sha256:99dcfdaaeb17caf6e526f32b6a7b780461512ab3f1d992187801694cba42770c",
"sha256:a84b8c9ab6239b578f22d1c21d51b696dcfe004032bb80ea832398d6909d7279"
],
"index": "pypi",
"version": "==2.20.0"
},
"six": {
"hashes": [
"sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9",
"sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb"
],
"version": "==1.11.0"
},
"text-unidecode": {
"hashes": [
"sha256:5a1375bb2ba7968740508ae38d92e1f889a0832913cb1c447d5e2046061a396d",
"sha256:801e38bd550b943563660a91de8d4b6fa5df60a542be9093f7abf819f86050cc"
],
"version": "==1.2"
},
"urllib3": {
"hashes": [
"sha256:61bf29cada3fc2fbefad4fdf059ea4bd1b4a86d2b6d15e1c7c0b582b9752fe39",
"sha256:de9529817c93f27c8ccbfead6985011db27bd0ddfcdb2d86f3f663385c6a9c22"
],
"version": "==1.24.1"
}
}
}

View File

@ -96,7 +96,7 @@ python manage.py loaddata initial_project_templates
python manage.py sample_data
```
**IMPORTANT: Taiga only runs with python 3.4+**
**IMPORTANT: Taiga only runs with python 3.5+**
Initial auth data: admin/123123

View File

@ -1,3 +1,6 @@
[pytest]
DJANGO_SETTINGS_MODULE = settings.testing
python_paths = .
filterwarnings =
once
ignore::django.utils.deprecation.RemovedInDjango20Warning
ignore::DeprecationWarning:taiga.base.api.serializers

View File

@ -1,11 +0,0 @@
-r requirements.txt
coverage==4.4.1
coveralls==1.1
django-slowdown==0.0.1
factory_boy==2.8.1
py==1.4.34
pytest-django==3.1.2
pytest-pythonpath==0.7.1
pytest==3.1.1
transifex-client==0.12.4

View File

@ -1,45 +1,63 @@
#djangorestframework==2.3.13 # It's not necessary since Taiga 1.7
CairoSVG==2.0.3
Django==1.11.2
git+git://github.com/Python-Markdown/markdown.git@a4d4b61b5ce4a7dd96ddc13c66b8716d3ad8eb51 # This version solves 'infinite execution' and other issues.
Pillow==4.1.1
PyJWT==1.5.0
Unidecode==0.4.20
amqp==2.1.4
-i https://pypi.org/simple
amqp==2.3.2
asana==0.6.7
bleach==2.0.0
asn1crypto==0.24.0
billiard==3.5.0.5
bleach==2.1.4
cairocffi==0.9.0
cairosvg==2.0.3
celery==4.0.2
kombu==4.0.2
cryptography==1.9
certifi==2018.11.29
cffi==1.11.5
chardet==3.0.4
contextlib2==0.5.5
cryptography==2.3.1
cssselect==1.0.3
cssutils==1.0.2
diff-match-patch==20121119
django-ipware==1.1.6
django-jinja==2.3.1
django-pglocks==1.0.2
django-picklefield==0.3.2
django-sampledatahelper==0.4.1
django-sites==0.9
django-sr==0.0.4
django==1.11.16
djmail==1.0.1
docopt==0.6.2
easy-thumbnails==2.4.1
fn==0.4.3
git+https://github.com/Xof/django-pglocks.git
gunicorn==19.7.1
jinja2==2.9.6
idna==2.5
lxml==3.8.0
html5lib==1.0.1
idna==2.7
jinja2==2.10
kombu==4.0.2
lxml==4.2.5
markdown==3.0.1
markupsafe==1.1.0
netaddr==0.7.19
oauthlib==2.1.0
olefile==0.46
pillow==4.1.1
premailer==3.0.1
psd-tools==1.4
psycopg2==2.7.4
psycopg2-binary==2.7.5
pycparser==2.19
pygments==2.2.0
pyjwkest==1.3.2
python-dateutil==2.6.0
pyjwt==1.7.0
python-dateutil==2.7.5
python-magic==0.4.13
pytz==2017.2
pytz==2018.7
raven==6.1.0
redis==2.10.5
requests-oauthlib==0.8.0
requests==2.17.3
requests==2.20.0
sampledata==0.3.7
serpy==0.1.1
six==1.10.0
six==1.11.0
tinycss==0.4
unidecode==0.4.20
urllib3==1.24.1
vine==1.1.4
webcolors==1.7
webencodings==0.5.1

View File

@ -242,11 +242,7 @@ You need transifex-client, install it.
1. Install transifex-client, use
$ pip install --upgrade -r requirements-devel.txt
or
$ pip install --upgrade transifex-client==0.12.2
$ pip install --upgrade transifex-client
2. Create ~/.transifexrc file:

View File

@ -1,4 +0,0 @@
from .celery import *
# To use celery in memory
#task_always_eager = True

View File

@ -148,7 +148,7 @@ LANGUAGES = [
("tr", "Türkçe"), # Turkish
#("tt", "татар теле"), # Tatar
#("udm", "удмурт кыл"), # Udmurt
#("uk", "Українська"), # Ukrainian
("uk", "Українська"), # Ukrainian
#("ur", "اردو‏"), # Urdu
#("vi", "Tiếng Việt"), # Vietnamese
("zh-hans", "中文(简体)"), # Simplified Chinese
@ -309,6 +309,7 @@ INSTALLED_APPS = [
"taiga.projects.issues",
"taiga.projects.wiki",
"taiga.projects.contact",
"taiga.projects.settings",
"taiga.searches",
"taiga.timeline",
"taiga.mdrender",

View File

@ -2,7 +2,10 @@
ignore = E41,E266
max-line-length = 120
exclude =
.cache,
.git,
.tox,
.venv,
*__pycache__*,
*tests*,
*scripts*,

View File

@ -375,14 +375,18 @@ class IsProjectAdminFromWebhookLogFilterBackend(FilterBackend, BaseIsProjectAdmi
class BaseRelatedFieldsFilter(FilterBackend):
filter_name = None
param_name = None
exclude_param_name = None
def __init__(self, filter_name=None, param_name=None):
def __init__(self, filter_name=None, param_name=None, exclude_param_name=None):
if filter_name:
self.filter_name = filter_name
if param_name:
self.param_name = param_name
if exclude_param_name:
self.exclude_param_name
def _prepare_filter_data(self, query_param_value):
def _transform_value(value):
try:
@ -396,48 +400,57 @@ class BaseRelatedFieldsFilter(FilterBackend):
values = map(_transform_value, values)
return list(values)
def _get_queryparams(self, params):
param_name = self.param_name or self.filter_name
def _get_queryparams(self, params, mode=''):
param_name = self.exclude_param_name if mode == 'exclude' else self.param_name or self.filter_name
raw_value = params.get(param_name, None)
if raw_value:
value = self._prepare_filter_data(raw_value)
if None in value:
qs_in_kwargs = {"{}__in".format(self.filter_name): [v for v in value if v is not None]}
qs_isnull_kwargs = {"{}__isnull".format(self.filter_name): True}
return Q(**qs_in_kwargs) | Q(**qs_isnull_kwargs)
else:
return {"{}__in".format(self.filter_name): value}
return Q(**{"{}__in".format(self.filter_name): value})
return None
def _prepare_filter_query(self, query):
return query
def _prepare_exclude_query(self, query):
return ~Q(query)
def filter_queryset(self, request, queryset, view):
query = self._get_queryparams(request.QUERY_PARAMS)
if query:
if isinstance(query, dict):
queryset = queryset.filter(**query)
else:
queryset = queryset.filter(query)
operations = {
"filter": self._prepare_filter_query,
"exclude": self._prepare_exclude_query,
}
for mode, prepare_method in operations.items():
query = self._get_queryparams(request.QUERY_PARAMS, mode=mode)
if query:
queryset = queryset.filter(prepare_method(query))
return super().filter_queryset(request, queryset, view)
class OwnersFilter(BaseRelatedFieldsFilter):
filter_name = 'owner'
exclude_param_name = 'exclude_owner'
class AssignedToFilter(BaseRelatedFieldsFilter):
filter_name = 'assigned_to'
exclude_param_name = 'exclude_assigned_to'
class AssignedUsersFilter(FilterModelAssignedUsers, BaseRelatedFieldsFilter):
filter_name = 'assigned_users'
exclude_param_name = 'exclude_assigned_users'
def _get_queryparams(self, params):
param_name = self.param_name or self.filter_name
def _get_queryparams(self, params, mode=''):
param_name = self.exclude_param_name if mode == 'exclude' else self.param_name or self.filter_name
raw_value = params.get(param_name, None)
if raw_value:
value = self._prepare_filter_data(raw_value)
UserStoryModel = apps.get_model("userstories", "UserStory")
@ -461,38 +474,65 @@ class AssignedUsersFilter(FilterModelAssignedUsers, BaseRelatedFieldsFilter):
class StatusesFilter(BaseRelatedFieldsFilter):
filter_name = 'status'
exclude_param_name = 'exclude_status'
class IssueTypesFilter(BaseRelatedFieldsFilter):
filter_name = 'type'
param_name = 'type'
exclude_param_name = 'exclude_type'
class PrioritiesFilter(BaseRelatedFieldsFilter):
filter_name = 'priority'
exclude_param_name = 'exclude_priority'
class SeveritiesFilter(BaseRelatedFieldsFilter):
filter_name = 'severity'
exclude_param_name = 'exclude_severity'
class TagsFilter(FilterBackend):
filter_name = 'tags'
exclude_param_name = 'exclude_tags'
def __init__(self, filter_name=None):
def __init__(self, filter_name=None, exclude_param_name=None):
if filter_name:
self.filter_name = filter_name
def _get_tags_queryparams(self, params):
tags = params.get(self.filter_name, None)
if exclude_param_name:
self.exclude_param_name = exclude_param_name
def _get_tags_queryparams(self, params, mode=''):
param_name = self.exclude_param_name if mode == "exclude" else self.filter_name
tags = params.get(param_name, None)
if tags:
return tags.split(",")
return None
def _prepare_filter_query(self, query):
return Q(tags__contains=query)
def _prepare_exclude_query(self, tags):
queries = [Q(tags__contains=[tag]) for tag in tags]
query = queries.pop()
for item in queries:
query |= item
return ~Q(query)
def filter_queryset(self, request, queryset, view):
query_tags = self._get_tags_queryparams(request.QUERY_PARAMS)
if query_tags:
queryset = queryset.filter(tags__contains=query_tags)
operations = {
"filter": self._prepare_filter_query,
"exclude": self._prepare_exclude_query,
}
for mode, prepare_method in operations.items():
query = self._get_tags_queryparams(request.QUERY_PARAMS, mode=mode)
if query:
queryset = queryset.filter(prepare_method(query))
return super().filter_queryset(request, queryset, view)
@ -631,18 +671,22 @@ class QFilter(FilterBackend):
class RoleFilter(BaseRelatedFieldsFilter):
filter_name = "role_id"
param_name = "role"
exclude_param_name = "exclude_role"
def filter_queryset(self, request, queryset, view):
Membership = apps.get_model('projects', 'Membership')
query = self._get_queryparams(request.QUERY_PARAMS)
if query:
if isinstance(query, dict):
memberships = Membership.objects.filter(**query).values_list("user_id", flat=True)
queryset = queryset.filter(assigned_to__in=memberships)
else:
memberships = Membership.objects.filter(query).values_list("user_id", flat=True)
if memberships:
queryset = queryset.filter(assigned_to__in=memberships)
operations = {
"filter": self._prepare_filter_query,
"exclude": self._prepare_exclude_query,
}
for mode, qs_method in operations.items():
query = self._get_queryparams(request.QUERY_PARAMS, mode=mode)
if query:
memberships = Membership.objects.filter(query).exclude(user__isnull=True).values_list("user_id", flat=True)
if memberships:
queryset = queryset.filter(qs_method(Q(assigned_to__in=memberships)))
return FilterBackend.filter_queryset(self, request, queryset, view)
@ -650,20 +694,24 @@ class RoleFilter(BaseRelatedFieldsFilter):
class UserStoriesRoleFilter(FilterModelAssignedUsers, BaseRelatedFieldsFilter):
filter_name = "role_id"
param_name = "role"
exclude_param_name = 'exclude_role'
def filter_queryset(self, request, queryset, view):
Membership = apps.get_model('projects', 'Membership')
query = self._get_queryparams(request.QUERY_PARAMS)
if query:
if isinstance(query, dict):
memberships = Membership.objects.filter(**query).values_list("user_id", flat=True)
else:
memberships = Membership.objects.filter(query).values_list("user_id", flat=True)
if memberships:
user_story_model = apps.get_model("userstories", "UserStory")
queryset = queryset.filter(
self.get_assigned_users_filter(user_story_model, memberships)
)
operations = {
"filter": self._prepare_filter_query,
"exclude": self._prepare_exclude_query,
}
for mode, qs_method in operations.items():
query = self._get_queryparams(request.QUERY_PARAMS, mode=mode)
if query:
memberships = Membership.objects.filter(query).exclude(user__isnull=True).values_list("user_id", flat=True)
if memberships:
user_story_model = apps.get_model("userstories", "UserStory")
queryset = queryset.filter(
qs_method(Q(self.get_assigned_users_filter(user_story_model, memberships)))
)
return FilterBackend.filter_queryset(self, request, queryset, view)

View File

@ -91,6 +91,22 @@ def emit_event_for_model(obj, *, type:str="change", channel:str="events",
sessionid=sessionid,
data=data)
def emit_event_for_user_notification(user_id,
*,
session_id: str=None,
event_type: str=None,
data: dict=None):
"""
Sends a user notification event.
"""
return emit_event(
data,
"web_notifications.{}".format(user_id),
sessionid=session_id
)
def emit_live_notification_for_model(obj, user, history, *, type:str="change", channel:str="events",
sessionid:str="not-existing"):
"""

View File

@ -19,6 +19,8 @@
from taiga.users.models import User
from taiga.projects.models import Membership
from taiga.permissions.choices import ANON_PERMISSIONS
def resolve_users_bindings(users_bindings):
new_users_bindings = {}
@ -50,3 +52,15 @@ def create_memberships(users_bindings, project, creator, role_name):
is_admin=False,
invited_by=creator,
)
def set_base_permissions_for_project(project):
if project.is_private:
return
anon_permissions = list(
map(lambda perm: perm[0], ANON_PERMISSIONS))
project.anon_permissions = list(
set((project.anon_permissions or []) + anon_permissions))
project.public_permissions = list(
set((project.public_permissions or []) + anon_permissions))
project.save()

View File

@ -262,6 +262,7 @@ class TrelloImporter:
project=project
)
import_service.create_memberships(options.get('users_bindings', {}), project, self._user, "trello")
import_service.set_base_permissions_for_project(project)
return project
def _import_user_stories_data(self, data, project, options):

View File

@ -27,8 +27,8 @@ msgstr ""
"Project-Id-Version: taiga-back\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2018-10-11 14:11+0200\n"
"PO-Revision-Date: 2018-10-14 17:29+0000\n"
"Last-Translator: Alejandro Hermida <alexhermida@gmail.com>\n"
"PO-Revision-Date: 2018-11-14 12:59+0000\n"
"Last-Translator: Hans Raaf\n"
"Language-Team: German (http://www.transifex.com/taiga-agile-llc/taiga-back/"
"language/de/)\n"
"MIME-Version: 1.0\n"
@ -557,7 +557,7 @@ msgstr ""
#: taiga/events/events.py:106
msgid "User story created"
msgstr "User story erstellt"
msgstr "User-Story erstellt"
#: taiga/events/events.py:109
msgid "User story changed"
@ -2841,8 +2841,8 @@ msgid ""
msgstr ""
"\n"
"<h1>Neues Ticket wurde erstellt</h1>\n"
"<p>Hallo %(user)s,<br />%(changer)s hat ein neues Ticket erstellt in "
"%(project)s</p>\n"
"<p>Hallo %(user)s,<br />%(changer)s hat ein neues Ticket in "
"%(project)s erstellt</p>\n"
"<p>Ticket #%(ref)s %(subject)s</p>\n"
"<a class=\"button\" href=\"%(url)s\" title=\"See Issue #%(ref)s %(subject)s"
"\">Ticket ansehen</a>\n"
@ -2862,7 +2862,7 @@ msgid ""
msgstr ""
"\n"
"Neues Ticket wurde erstellt\n"
"Hallo %(user)s, %(changer)s hat ein neues Ticket erstellt in %(project)s\n"
"Hallo %(user)s, %(changer)s hat ein neues Ticket in %(project)s erstellt\n"
"Ticket ansehen #%(ref)s %(subject)s auf %(url)s\n"
"\n"
"---\n"
@ -2988,8 +2988,8 @@ msgid ""
msgstr ""
"\n"
"<h1>Neuer Sprint wurde erstellt</h1>\n"
"<p>Hallo %(user)s,<br />%(changer)s hat einen neuen Sprint erstellt in "
"%(project)s</p>\n"
"<p>Hallo %(user)s,<br />%(changer)s hat einen neuen Sprint in "
"%(project)s erstellt</p>\n"
"<p>Sprint %(name)s</p>\n"
"<a class=\"button\" href=\"%(url)s\" title=\"See Sprint %(name)s\">See "
"sprint</a>\n"
@ -3009,7 +3009,7 @@ msgid ""
msgstr ""
"\n"
"Neuer Sprint wurde erstellt\n"
"Hallo %(user)s, %(changer)s hat einen neuen Sprint erstellt in %(project)s\n"
"Hallo %(user)s, %(changer)s hat einen neuen Sprint in %(project)s erstellt\n"
"Sprint ansehen %(name)s at %(url)s\n"
"\n"
"---\n"
@ -3058,7 +3058,7 @@ msgid ""
msgstr ""
"\n"
"Sprint wurde gelöscht\n"
"Hallo %(user)s, %(changer)s hat einen Sprint gelöscht in %(project)s\n"
"Hallo %(user)s, %(changer)s hat einen Sprint in %(project)s gelöscht \n"
"Sprint %(name)s\n"
"\n"
"---\n"
@ -3135,8 +3135,8 @@ msgid ""
msgstr ""
"\n"
"<h1>Neue Aufgabe wurde erstellt</h1>\n"
"<p>Hallo %(user)s,<br />%(changer)s hat eine neue Aufgabe erstellt in "
"%(project)s</p>\n"
"<p>Hallo %(user)s,<br />%(changer)s hat eine neue Aufgabe in "
"%(project)s erstellt</p>\n"
"<p>Aufgabe #%(ref)s %(subject)s</p>\n"
"<a class=\"button\" href=\"%(url)s\" title=\"See Task #%(ref)s %(subject)s"
"\">Aufgabe ansehen</a>\n"
@ -3156,7 +3156,7 @@ msgid ""
msgstr ""
"\n"
"Neue Aufgabe wurde erstellt\n"
"Hallo %(user)s, %(changer)s hat eine neue Aufgabe erstellt in %(project)s\n"
"Hallo %(user)s, %(changer)s hat eine neue Aufgabe in %(project)s erstellt\n"
"Aufgabe ansehen #%(ref)s %(subject)s auf %(url)s\n"
"\n"
"\n"
@ -3187,8 +3187,8 @@ msgid ""
msgstr ""
"\n"
"<h1>Aufgabe wurde gelöscht</h1>\n"
"<p>Hallo %(user)s,<br />%(changer)s hat eine Aufgabe gelöscht in "
"%(project)s</p>\n"
"<p>Hallo %(user)s,<br />%(changer)s hat eine Aufgabe in %(project)s "
"gelöscht</p>\n"
"<p>Aufgabe #%(ref)s %(subject)s</p>\n"
"<p><small>Das Taiga Team</small></p>\n"
" "
@ -3206,7 +3206,7 @@ msgid ""
msgstr ""
"\n"
"Aufgabe wurde gelöscht\n"
"Hallo %(user)s, %(changer)s hat eine Aufgabe gelöscht in %(project)s\n"
"Hallo %(user)s, %(changer)s hat eine Aufgabe in %(project)s gelöscht\n"
"Aufgabe #%(ref)s %(subject)s\n"
"\n"
"---\n"
@ -3283,8 +3283,8 @@ msgid ""
msgstr ""
"\n"
"<h1>Neue User-Story wurde erstellt</h1>\n"
"<p>Hallo %(user)s,<br />%(changer)s hat eine neue User-Story erstellt in "
"%(project)s</p>\n"
"<p>Hallo %(user)s,<br />%(changer)s hat eine neue User-Story in "
"%(project)s erstellt</p>\n"
"<p>User-Story #%(ref)s %(subject)s</p>\n"
"<a class=\"button\" href=\"%(url)s\" title=\"See User Story #%(ref)s "
"%(subject)s\">User-Story ansehen</a>\n"
@ -3305,8 +3305,8 @@ msgid ""
msgstr ""
"\n"
"Neue User-Story wurde erstellt\n"
"Hallo %(user)s, %(changer)s hat eine neue User-Story erstellt in "
"%(project)s\n"
"Hallo %(user)s, %(changer)s hat eine neue User-Story in %(project)s "
"erstellt\n"
"User-Story ansehen #%(ref)s %(subject)s auf %(url)s\n"
"\n"
"---\n"
@ -3336,8 +3336,8 @@ msgid ""
msgstr ""
"\n"
"<h1>User-Story wurde gelöscht</h1>\n"
"<p>Hallo %(user)s,<br />%(changer)s hat eine User-Story gelöscht in "
"%(project)s</p>\n"
"<p>Hallo %(user)s,<br />%(changer)s hat eine User-Story in %(project)s "
"gelöscht</p>\n"
"<p>User-Story #%(ref)s %(subject)s</p>\n"
"<p><small>Das Taiga Team</small></p>\n"
"\n"
@ -3356,7 +3356,7 @@ msgid ""
msgstr ""
"\n"
"User-Story wurde gelöscht\n"
"Hallo %(user)s, %(changer)s hat eine User-Story gelöscht in %(project)s\n"
"Hallo %(user)s, %(changer)s hat eine User-Story in %(project)s gelöscht\n"
"User-Story #%(ref)s %(subject)s\n"
"\n"
"---\n"
@ -3438,8 +3438,8 @@ msgid ""
msgstr ""
"\n"
"<h1>Neue Wiki Seite wurde erstellt</h1>\n"
"<p>Hallo %(user)s,<br />%(changer)s hat eine neue Wiki Seite erstellt in "
"%(project)s</p>\n"
"<p>Hallo %(user)s,<br />%(changer)s hat eine neue Wiki Seite in "
"%(project)s erstellt</p>\n"
"<p>Wiki Seite %(page)s</p>\n"
"<a class=\"button\" href=\"%(url)s\" title=\"Wiki page %(page)s\">Wiki Seite "
"ansehen</a>\n"
@ -3463,8 +3463,8 @@ msgstr ""
"\n"
"Neue Wiki Seite wurde erstellt\n"
"\n"
"Hallo %(user)s, %(changer)s hat eine neue Wiki Seite erstellt in "
"%(project)s\n"
"Hallo %(user)s, %(changer)s hat eine neue Wiki Seite in %(project)s "
"erstellt\n"
"\n"
"Wiki Seite ansehen %(page)s auf %(url)s\n"
"\n"
@ -3495,8 +3495,8 @@ msgid ""
msgstr ""
"\n"
"<h1>Wiki Seite wurde gelöscht</h1>\n"
"<p>Hallo %(user)s,<br />%(changer)s hat eine Wiki Seite gelöscht in "
"%(project)s</p>\n"
"<p>Hallo %(user)s,<br />%(changer)s hat eine Wiki Seite in "
"%(project)s gelöscht</p>\n"
"<p>Wiki Seite %(page)s</p>\n"
"<p><small>Das Taiga Team</small></p>\n"
"\n"
@ -3518,7 +3518,7 @@ msgstr ""
"\n"
"Wiki Seite wurde gelöscht\n"
"\n"
"Hallo %(user)s, %(changer)s hat eine Wiki Seite gelöscht in %(project)s\n"
"Hallo %(user)s, %(changer)s hat eine Wiki Seite in %(project)s gelöscht\n"
"\n"
"Wiki Seite %(page)s\n"
"\n"

View File

@ -4,14 +4,16 @@
#
# Translators:
# Translators:
# Amirhoshang Hoseinpour Dehkordi <amir.hoseinpour@gmail.com>, 2018
# Vahid Dayyani <vahid.dayyani@ymail.com>, 2018
msgid ""
msgstr ""
"Project-Id-Version: taiga-back\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2018-10-11 14:11+0200\n"
"PO-Revision-Date: 2018-10-14 17:29+0000\n"
"Last-Translator: Alejandro Hermida <alexhermida@gmail.com>\n"
"PO-Revision-Date: 2018-12-02 10:54+0000\n"
"Last-Translator: Amirhoshang Hoseinpour Dehkordi <amir.hoseinpour@gmail."
"com>\n"
"Language-Team: Persian (Iran) (http://www.transifex.com/taiga-agile-llc/"
"taiga-back/language/fa_IR/)\n"
"MIME-Version: 1.0\n"
@ -26,7 +28,7 @@ msgstr "ثبت نام عمومی غیرفعال است."
#: taiga/auth/api.py:93
msgid "You must accept our terms of service and privacy policy"
msgstr ""
msgstr "شما باید موارد سرویس و سیاست های امنیت ما را قبول کنید."
#: taiga/auth/api.py:102
msgid "invalid register type"
@ -73,7 +75,7 @@ msgstr "نام کاربری نامعتبر"
#: taiga/auth/validators.py:42 taiga/users/validators.py:50
msgid ""
"Required. 255 characters or fewer. Letters, numbers and /./-/_ characters'"
msgstr "ضروری است. ۲۵۵ کاراکتر یا کمتر. حروف و اعداد و . و - و ـ مجاز است."
msgstr "255 کاراکتر یا کمتر ضروری است. حروف و اعداد و . و - و ـ مجاز است."
#: taiga/base/api/fields.py:294
msgid "This field is required."
@ -86,7 +88,7 @@ msgstr "مقدار نامعتبر."
#: taiga/base/api/fields.py:484
#, python-format
msgid "'%s' value must be either True or False."
msgstr "'%s' می‌بایست True (صحیح) یا False (غلط) باشد."
msgstr "'%s' می‌بایست صحیح یا غلط باشد."
#: taiga/base/api/fields.py:549
msgid ""
@ -517,91 +519,91 @@ msgstr ""
#: taiga/base/utils/urls.py:68
msgid "Host access error"
msgstr ""
msgstr "خطای دسترسی به هاست"
#: taiga/base/utils/urls.py:74
msgid "IP Address error"
msgstr ""
msgstr "خطای آدرس آی پی"
#: taiga/events/events.py:106
msgid "User story created"
msgstr ""
msgstr "گزارش کاربر ساخته شد."
#: taiga/events/events.py:109
msgid "User story changed"
msgstr ""
msgstr "گزارش کاربر تغییر کرد."
#: taiga/events/events.py:112
msgid "User story deleted"
msgstr ""
msgstr "گزارش کاربر حذف شد."
#: taiga/events/events.py:114
msgid "US #{} - {}"
msgstr ""
msgstr "US #{} - {}"
#: taiga/events/events.py:117
msgid "Task created"
msgstr ""
msgstr "وظیفه ساخته شد."
#: taiga/events/events.py:120
msgid "Task changed"
msgstr ""
msgstr "وظیفه تغییر کرد."
#: taiga/events/events.py:123
msgid "Task deleted"
msgstr ""
msgstr "وظیفه حذف شد."
#: taiga/events/events.py:125
msgid "Task #{} - {}"
msgstr ""
msgstr "وظیفه #{} - {}"
#: taiga/events/events.py:128
msgid "Issue created"
msgstr ""
msgstr "موضوع ساخته شد."
#: taiga/events/events.py:131
msgid "Issue changed"
msgstr ""
msgstr "موضوع تغییر کرد."
#: taiga/events/events.py:134
msgid "Issue deleted"
msgstr ""
msgstr "موضوع حذف شد."
#: taiga/events/events.py:136
msgid "Issue: #{} - {}"
msgstr ""
msgstr "موضوع: #{} - {}"
#: taiga/events/events.py:139
msgid "Wiki Page created"
msgstr ""
msgstr "صفحه ویکی ساخته شد."
#: taiga/events/events.py:142
msgid "Wiki Page changed"
msgstr ""
msgstr "صفحه ویکی تغییر داده شد."
#: taiga/events/events.py:145
msgid "Wiki Page deleted"
msgstr ""
msgstr "صفحه ویکی حذف شد."
#: taiga/events/events.py:147
msgid "Wiki Page: {}"
msgstr ""
msgstr "صفحه ویکی: {}"
#: taiga/events/events.py:150
msgid "Sprint created"
msgstr ""
msgstr "سرعتی ساخته شد."
#: taiga/events/events.py:153
msgid "Sprint changed"
msgstr ""
msgstr "سرعتی تغییر داده شد."
#: taiga/events/events.py:156
msgid "Sprint deleted"
msgstr ""
msgstr "سرعتی حذف شد."
#: taiga/events/events.py:158
msgid "Sprint: {}"
msgstr ""
msgstr "سرعتی: {}"
#: taiga/export_import/api.py:127
msgid "We needed at least one role"
@ -1782,27 +1784,27 @@ msgstr "کاربر می‌بایست قبلاً از اعضای پروژه بو
#: taiga/projects/api.py:642
msgid "You can't delete user story due date by default"
msgstr ""
msgstr "شما سر رسید گزارش کاربر را نمی توانید بصورت عادی حذف کنید."
#: taiga/projects/api.py:658
msgid "Project already have due dates"
msgstr ""
msgstr "پروژه موعد سر رسید دارد."
#: taiga/projects/api.py:718
msgid "You can't delete task due date by default"
msgstr ""
msgstr "شما سر رسید وظیفه کاربر را نمی توانید بصورت عادی حذف کنید."
#: taiga/projects/api.py:734
msgid "Project already have task due dates"
msgstr ""
msgstr "پروژه موعد سر رسید وظیفه دارد."
#: taiga/projects/api.py:858
msgid "You can't delete issue due date by default"
msgstr ""
msgstr "شما سر رسید موضوع کاربر را نمی توانید بصورت عادی حذف کنید."
#: taiga/projects/api.py:874
msgid "Project already have issue due dates"
msgstr ""
msgstr "پروژه موعد سر رسید موضوع دارد."
#: taiga/projects/api.py:1023
msgid ""
@ -2051,11 +2053,11 @@ msgstr "موردی با همین نام وجود دارد."
#: taiga/projects/due_dates/models.py:21
msgid "due date"
msgstr ""
msgstr "موعد سر رسید"
#: taiga/projects/due_dates/models.py:24
msgid "reason for the due date"
msgstr ""
msgstr "دلیل موعد سر رسید."
#: taiga/projects/epics/api.py:94
msgid "You don't have permissions to set this status to this epic."
@ -2212,7 +2214,7 @@ msgstr "آزاد شد"
#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:164
#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:175
msgid "Not set"
msgstr ""
msgstr "تنظیم نشده"
#: taiga/projects/history/templates/emails/includes/fields_diff-html.jinja:286
#: taiga/projects/history/templates/emails/includes/fields_diff-text.jinja:91
@ -2567,12 +2569,12 @@ msgstr "ارزش"
#: taiga/projects/models.py:614 taiga/projects/models.py:671
#: taiga/projects/models.py:789
msgid "by default"
msgstr ""
msgstr "بصورت عمومی"
#: taiga/projects/models.py:618 taiga/projects/models.py:675
#: taiga/projects/models.py:793
msgid "days to due"
msgstr ""
msgstr "روز تا موعد"
#: taiga/projects/models.py:823
msgid "default owner's role"
@ -2592,7 +2594,7 @@ msgstr "وضعیت‌های استوری‌های کاربری"
#: taiga/projects/models.py:849
msgid "us duedates"
msgstr ""
msgstr "موعد ما"
#: taiga/projects/models.py:850 taiga/projects/userstories/models.py:45
#: taiga/projects/userstories/models.py:78
@ -2605,7 +2607,7 @@ msgstr "وضعیت‌های وظایف"
#: taiga/projects/models.py:852
msgid "task duedates"
msgstr ""
msgstr "موعد وظیفه ها"
#: taiga/projects/models.py:853
msgid "issue statuses"
@ -2617,7 +2619,7 @@ msgstr "انواع موضوعات"
#: taiga/projects/models.py:855
msgid "issue duedates"
msgstr ""
msgstr "موعد موضوعات"
#: taiga/projects/models.py:856
msgid "priorities"
@ -4221,7 +4223,7 @@ msgstr "؟"
#. Translators: User story point value (value = 0)
#: taiga/projects/translations.py:47
msgid "0"
msgstr "۰"
msgstr "0"
#. Translators: User story point value (value = 0.5)
#: taiga/projects/translations.py:49
@ -4461,7 +4463,7 @@ msgstr "تاریخ تکمیل"
#: taiga/projects/userstories/models.py:102
msgid "assigned users"
msgstr ""
msgstr "کاربران متصل شده"
#: taiga/projects/userstories/models.py:111
msgid "generated from issue"
@ -4724,11 +4726,11 @@ msgstr "تاریخ عضویت"
#: taiga/users/models.py:155
msgid "accepted terms"
msgstr ""
msgstr "قبول شرایط"
#: taiga/users/models.py:156
msgid "new terms read"
msgstr ""
msgstr "خواندن شرایط جدید"
#: taiga/users/models.py:158
msgid "default language"
@ -4972,7 +4974,7 @@ msgstr "نام کاربری نامعتبر. نام کاربری دیگری ان
#: taiga/users/validators.py:73
msgid "Read new terms has to be true'"
msgstr ""
msgstr "خواندن شرایط جدید باید صحیح باشد"
#: taiga/userstorage/api.py:53
msgid ""
@ -5017,4 +5019,4 @@ msgstr "مدت زمان"
#: taiga/webhooks/validators.py:42
msgid "Not allowed IP Address"
msgstr ""
msgstr "آی پی آدرس غیر مجاز"

View File

@ -10,13 +10,14 @@
# Shun Yanaura <metroplexity@gmail.com>, 2016
# Suguru Sato <usagi.vs.tanuki@gmail.com>, 2016
# Tomonori Tanabe <tanb+github@me.com>, 2015
# Masaki Honda <mh35jp@gmail.com>, 2018
msgid ""
msgstr ""
"Project-Id-Version: taiga-back\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2018-10-11 14:11+0200\n"
"PO-Revision-Date: 2018-10-14 17:29+0000\n"
"Last-Translator: Alejandro Hermida <alexhermida@gmail.com>\n"
"PO-Revision-Date: 2018-12-28 01:50+0000\n"
"Last-Translator: Masaki Honda <mh35jp@gmail.com>\n"
"Language-Team: Japanese (http://www.transifex.com/taiga-agile-llc/taiga-back/"
"language/ja/)\n"
"MIME-Version: 1.0\n"
@ -31,7 +32,7 @@ msgstr "パブリックなレジスタは無効です。"
#: taiga/auth/api.py:93
msgid "You must accept our terms of service and privacy policy"
msgstr ""
msgstr "利用規約とプライバシーポリシーに同意する必要があります"
#: taiga/auth/api.py:102
msgid "invalid register type"

View File

@ -6,13 +6,14 @@
# Translators:
# Dajo Hein, 2015
# Haroun Pacquee, 2015
# Joannes Anthonius Rommers <ja.rommers@lge.com>, 2018
msgid ""
msgstr ""
"Project-Id-Version: taiga-back\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2018-10-11 14:11+0200\n"
"PO-Revision-Date: 2018-10-14 17:29+0000\n"
"Last-Translator: Alejandro Hermida <alexhermida@gmail.com>\n"
"PO-Revision-Date: 2018-11-02 07:56+0000\n"
"Last-Translator: Joannes Anthonius Rommers <ja.rommers@lge.com>\n"
"Language-Team: Dutch (http://www.transifex.com/taiga-agile-llc/taiga-back/"
"language/nl/)\n"
"MIME-Version: 1.0\n"
@ -27,7 +28,7 @@ msgstr "Publieke registratie is uitgeschakeld."
#: taiga/auth/api.py:93
msgid "You must accept our terms of service and privacy policy"
msgstr ""
msgstr "U moet onze servicevoorwaarden en ons privacybeleid accepteren"
#: taiga/auth/api.py:102
msgid "invalid register type"
@ -55,7 +56,7 @@ msgstr "Gebruiker is al geregistreerd."
#: taiga/auth/services.py:141
msgid "This user is already a member of the project."
msgstr ""
msgstr "Deze gebruiker is al lid van het project."
#: taiga/auth/services.py:165
msgid "Error on creating new user."
@ -105,7 +106,7 @@ msgstr ""
#: taiga/base/api/fields.py:638
msgid "You email domain is not allowed"
msgstr ""
msgstr "Jou e-mail domein is niet toegestaan"
#: taiga/base/api/fields.py:647
msgid "Enter a valid email address."
@ -211,7 +212,7 @@ msgstr ""
#: taiga/projects/tasks/api.py:284 taiga/projects/userstories/api.py:346
#: taiga/projects/userstories/api.py:398 taiga/webhooks/api.py:71
msgid "Blocked element"
msgstr ""
msgstr "Geblokkeerd element"
#: taiga/base/api/pagination.py:228
msgid "Page is not 'last', nor can it be converted to an int."
@ -364,7 +365,7 @@ msgstr "Preconditie fout"
#: taiga/base/exceptions.py:219
msgid "No room left for more projects."
msgstr ""
msgstr "Er is geen ruimte over voor meer projecten."
#: taiga/base/filters.py:105 taiga/base/filters.py:526
msgid "Error in filter params types."
@ -486,6 +487,9 @@ msgid ""
"%(comment)s</p>\n"
" "
msgstr ""
"\n"
"<h3>commentaar:</h3>\n"
"<p>%(comment)s</p>"
#: taiga/base/templates/emails/updates-body-text.jinja:6
#, python-format
@ -500,91 +504,91 @@ msgstr ""
#: taiga/base/utils/urls.py:68
msgid "Host access error"
msgstr ""
msgstr "Host toegang fout"
#: taiga/base/utils/urls.py:74
msgid "IP Address error"
msgstr ""
msgstr "IP adres fout"
#: taiga/events/events.py:106
msgid "User story created"
msgstr ""
msgstr "User story aangemaakt"
#: taiga/events/events.py:109
msgid "User story changed"
msgstr ""
msgstr "User story gewijzigd"
#: taiga/events/events.py:112
msgid "User story deleted"
msgstr ""
msgstr "User story verwijderd"
#: taiga/events/events.py:114
msgid "US #{} - {}"
msgstr ""
msgstr "US #{} - {}"
#: taiga/events/events.py:117
msgid "Task created"
msgstr ""
msgstr "Taak aangemaakt"
#: taiga/events/events.py:120
msgid "Task changed"
msgstr ""
msgstr "Taak gewijzigd"
#: taiga/events/events.py:123
msgid "Task deleted"
msgstr ""
msgstr "Taak verwijderd"
#: taiga/events/events.py:125
msgid "Task #{} - {}"
msgstr ""
msgstr "Taak #{} - {}"
#: taiga/events/events.py:128
msgid "Issue created"
msgstr ""
msgstr "Probleem aangemaakt"
#: taiga/events/events.py:131
msgid "Issue changed"
msgstr ""
msgstr "Probleem gewijzigd"
#: taiga/events/events.py:134
msgid "Issue deleted"
msgstr ""
msgstr "Probleem verwijderd"
#: taiga/events/events.py:136
msgid "Issue: #{} - {}"
msgstr ""
msgstr "Probleem: #{} - {}"
#: taiga/events/events.py:139
msgid "Wiki Page created"
msgstr ""
msgstr "Wiki Pagina aangemaakt"
#: taiga/events/events.py:142
msgid "Wiki Page changed"
msgstr ""
msgstr "Wiki Pagina gewijzigd"
#: taiga/events/events.py:145
msgid "Wiki Page deleted"
msgstr ""
msgstr "Wiki Pagina verwijderd"
#: taiga/events/events.py:147
msgid "Wiki Page: {}"
msgstr ""
msgstr "Wiki pagina: {}"
#: taiga/events/events.py:150
msgid "Sprint created"
msgstr ""
msgstr "Sprint aangemaakt"
#: taiga/events/events.py:153
msgid "Sprint changed"
msgstr ""
msgstr "Sprint gewijzigd"
#: taiga/events/events.py:156
msgid "Sprint deleted"
msgstr ""
msgstr "Sprint verwijderd"
#: taiga/events/events.py:158
msgid "Sprint: {}"
msgstr ""
msgstr "Sprint: {}"
#: taiga/export_import/api.py:127
msgid "We needed at least one role"
@ -621,23 +625,23 @@ msgstr "fout bij importeren van standaard projectattributen waarden"
#: taiga/export_import/services/store.py:779
msgid "error importing custom attributes"
msgstr "fout bij importeren eigen attributen"
msgstr "fout bij het importeren van aangepaste attributen"
#: taiga/export_import/services/store.py:783
msgid "error importing sprints"
msgstr "fout bij importeren sprints"
msgstr "fout bij het importeren van sprints"
#: taiga/export_import/services/store.py:787
msgid "error importing issues"
msgstr "fout bij importeren issues"
msgstr "fout bij het importeren van problemen"
#: taiga/export_import/services/store.py:791
msgid "error importing user stories"
msgstr "fout bij importeren user stories"
msgstr "fout bij het importeren van user stories"
#: taiga/export_import/services/store.py:795
msgid "error importing epics"
msgstr ""
msgstr "fout bij het importeren van epics"
#: taiga/export_import/services/store.py:799
msgid "error importing tasks"
@ -661,7 +665,7 @@ msgstr "fout bij importeren tijdlijnen"
#: taiga/export_import/services/store.py:837
msgid "unexpected error importing project"
msgstr ""
msgstr "onverwachte fout tijdens het importeren van het project"
#: taiga/export_import/tasks.py:62 taiga/export_import/tasks.py:63
msgid "Error generating project dump"
@ -693,11 +697,11 @@ msgstr "Fout bij laden project dump"
#: taiga/export_import/tasks.py:121
msgid "Error loading your project dump file"
msgstr ""
msgstr "Fout tijdens het laden van de project dump file"
#: taiga/export_import/tasks.py:135
msgid " -- no detail info --"
msgstr ""
msgstr "-- geen gedetailleerde informatie --"
#: taiga/export_import/templates/emails/dump_project-body-html.jinja:4
#, python-format
@ -893,7 +897,7 @@ msgstr "Naam gedupliceerd voor het project"
#: taiga/external_apps/api.py:43 taiga/external_apps/api.py:70
#: taiga/external_apps/api.py:77
msgid "Authentication required"
msgstr ""
msgstr "Authenticatie verreist"
#: taiga/external_apps/models.py:35
#: taiga/projects/custom_attributes/models.py:36
@ -911,11 +915,11 @@ msgstr "naam"
#: taiga/external_apps/models.py:37
msgid "Icon url"
msgstr ""
msgstr "Icoon url"
#: taiga/external_apps/models.py:38
msgid "web"
msgstr ""
msgstr "web"
#: taiga/external_apps/models.py:39 taiga/projects/attachments/models.py:62
#: taiga/projects/custom_attributes/models.py:37
@ -929,17 +933,17 @@ msgstr "omschrijving"
#: taiga/external_apps/models.py:41
msgid "Next url"
msgstr ""
msgstr "Volgende url"
#: taiga/external_apps/models.py:55 taiga/projects/contact/models.py:26
#: taiga/projects/likes/models.py:31 taiga/projects/notifications/models.py:88
#: taiga/projects/votes/models.py:52
msgid "user"
msgstr ""
msgstr "gebruiker"
#: taiga/external_apps/models.py:59
msgid "application"
msgstr ""
msgstr "applicatie"
#: taiga/feedback/models.py:25 taiga/users/models.py:147
msgid "full name"

View File

@ -18,6 +18,7 @@
# Lucas Boscaini <lucasboscaini@gmail.com>, 2017
# Mairieli Wessel <mairieliw@alunos.utfpr.edu.br>, 2016
# Marlon Carvalho <m.lopes@qiwi.com>, 2015
# Michael Douglas Meneses de Souza <michael.douglas.meneses.2@gmail.com>, 2018
# Michel Wilhelm <michelwilhelm@gmail.com>, 2016
# pedromvm <pedromvm@gmail.com>, 2015
# Pedro Rangel Raft <me@pedroraft.com>, 2017
@ -30,8 +31,9 @@ msgstr ""
"Project-Id-Version: taiga-back\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2018-10-11 14:11+0200\n"
"PO-Revision-Date: 2018-10-14 17:29+0000\n"
"Last-Translator: Alejandro Hermida <alexhermida@gmail.com>\n"
"PO-Revision-Date: 2018-11-17 17:51+0000\n"
"Last-Translator: Michael Douglas Meneses de Souza <michael.douglas."
"meneses.2@gmail.com>\n"
"Language-Team: Portuguese (Brazil) (http://www.transifex.com/taiga-agile-llc/"
"taiga-back/language/pt_BR/)\n"
"MIME-Version: 1.0\n"
@ -46,7 +48,7 @@ msgstr "Registro público está desabilitado. "
#: taiga/auth/api.py:93
msgid "You must accept our terms of service and privacy policy"
msgstr ""
msgstr "Você deve aceitar os termos do serviço e a política de privacidade"
#: taiga/auth/api.py:102
msgid "invalid register type"
@ -525,83 +527,83 @@ msgstr ""
#: taiga/events/events.py:106
msgid "User story created"
msgstr ""
msgstr "História de Usuário criada"
#: taiga/events/events.py:109
msgid "User story changed"
msgstr ""
msgstr "História de Usuário alterada"
#: taiga/events/events.py:112
msgid "User story deleted"
msgstr ""
msgstr "História de Usuário excluída"
#: taiga/events/events.py:114
msgid "US #{} - {}"
msgstr ""
msgstr "US #{} - {}"
#: taiga/events/events.py:117
msgid "Task created"
msgstr ""
msgstr "Tarefa criada"
#: taiga/events/events.py:120
msgid "Task changed"
msgstr ""
msgstr "Tarefa alterada"
#: taiga/events/events.py:123
msgid "Task deleted"
msgstr ""
msgstr "Tarefa excluída"
#: taiga/events/events.py:125
msgid "Task #{} - {}"
msgstr ""
msgstr "Tarefa #{} - {}"
#: taiga/events/events.py:128
msgid "Issue created"
msgstr ""
msgstr "Issue criada"
#: taiga/events/events.py:131
msgid "Issue changed"
msgstr ""
msgstr "Issue alterada"
#: taiga/events/events.py:134
msgid "Issue deleted"
msgstr ""
msgstr "Issue excluída"
#: taiga/events/events.py:136
msgid "Issue: #{} - {}"
msgstr ""
msgstr "Issue: #{} - {}"
#: taiga/events/events.py:139
msgid "Wiki Page created"
msgstr ""
msgstr "Página Wiki criada"
#: taiga/events/events.py:142
msgid "Wiki Page changed"
msgstr ""
msgstr "Página Wiki alterada"
#: taiga/events/events.py:145
msgid "Wiki Page deleted"
msgstr ""
msgstr "Página Wiki excluída"
#: taiga/events/events.py:147
msgid "Wiki Page: {}"
msgstr ""
msgstr "Página Wiki: {}"
#: taiga/events/events.py:150
msgid "Sprint created"
msgstr ""
msgstr "Sprint criada"
#: taiga/events/events.py:153
msgid "Sprint changed"
msgstr ""
msgstr "Sprint alterada"
#: taiga/events/events.py:156
msgid "Sprint deleted"
msgstr ""
msgstr "Sprint excluída"
#: taiga/events/events.py:158
msgid "Sprint: {}"
msgstr ""
msgstr "Sprint: {}"
#: taiga/export_import/api.py:127
msgid "We needed at least one role"
@ -1279,7 +1281,7 @@ msgstr "O parâmetro url é necessário"
#: taiga/importers/jira/api.py:158
msgid "Invalid project_type {}"
msgstr ""
msgstr "project_type inválido {}"
#: taiga/importers/jira/api.py:192
msgid "Invalid Jira server configuration."
@ -1518,7 +1520,7 @@ msgstr "Pedido invalido: %s há %s"
#: taiga/importers/trello/importer.py:80 taiga/importers/trello/importer.py:82
#, python-format
msgid "Unauthorized: %s at %s"
msgstr ""
msgstr "Não autorizado: %s até %s"
#: taiga/importers/trello/importer.py:84 taiga/importers/trello/importer.py:86
#, python-format

File diff suppressed because it is too large Load Diff

View File

@ -4,6 +4,7 @@
#
# Translators:
# Translators:
# Andy zhan <daliangzao189@126.com>, 2018
# Ares <yangzibin.cn@hotmail.com>, 2017
# gm l <linguangmo@gmail.com>, 2016
# Hanbing Yin <yin_suk@hotmail.com>, 2016
@ -17,7 +18,7 @@
# Yang Yu <yuyangmkdir@yahoo.com>, 2016
# yonee <yangqo@hotmail.com>, 2015
# 5791113 <yonglong.ma@outlook.com>, 2016
# yungang <zangyungang@gmail.com>, 2018
# zangyg <zangyungang@gmail.com>, 2018
# 5791113 <yonglong.ma@outlook.com>, 2016
# 朱坚 <garyzhu2009@gmail.com>, 2017
msgid ""
@ -25,8 +26,8 @@ msgstr ""
"Project-Id-Version: taiga-back\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2018-10-11 14:11+0200\n"
"PO-Revision-Date: 2018-10-14 17:29+0000\n"
"Last-Translator: Alejandro Hermida <alexhermida@gmail.com>\n"
"PO-Revision-Date: 2018-12-16 07:10+0000\n"
"Last-Translator: Andy zhan <daliangzao189@126.com>\n"
"Language-Team: Chinese Simplified (http://www.transifex.com/taiga-agile-llc/"
"taiga-back/language/zh-Hans/)\n"
"MIME-Version: 1.0\n"
@ -4649,11 +4650,11 @@ msgstr "加入日期"
#: taiga/users/models.py:155
msgid "accepted terms"
msgstr ""
msgstr "已接受的条款"
#: taiga/users/models.py:156
msgid "new terms read"
msgstr ""
msgstr "阅读新条款"
#: taiga/users/models.py:158
msgid "default language"
@ -4894,7 +4895,7 @@ msgstr "无效用户名,请尝试其他的。"
#: taiga/users/validators.py:73
msgid "Read new terms has to be true'"
msgstr ""
msgstr "还没有阅读新条款"
#: taiga/userstorage/api.py:53
msgid ""
@ -4939,4 +4940,4 @@ msgstr "持续时间"
#: taiga/webhooks/validators.py:42
msgid "Not allowed IP Address"
msgstr ""
msgstr "IP地址被禁用"

View File

@ -37,7 +37,7 @@ class AutolinkExtension(markdown.Extension):
* GitHub only accepts URLs with protocols or "www.", whereas Gruber's regex
accepts things like "foo.com/bar".
"""
def extendMarkdown(self, md, md_globals):
def extendMarkdown(self, md):
url_re = r'(?i)\b((?:(?:ftp|https?)://|www\d{0,3}[.])([^\s<>]+))'
autolink = AutolinkPattern(url_re, md)
md.inlinePatterns.add('gfm-autolink', autolink, '_end')

View File

@ -19,7 +19,7 @@ class AutomailPattern(markdown.inlinepatterns.Pattern):
class AutomailExtension(markdown.Extension):
"""An extension that turns all email addresses into links."""
def extendMarkdown(self, md, md_globals):
def extendMarkdown(self, md):
mail_re = r'\b(?i)([a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]+)\b'
automail = AutomailPattern(mail_re, md)
md.inlinePatterns.add('gfm-automail', automail, '_end')

View File

@ -152,7 +152,7 @@ EMOJIS_SET = {
class EmojifyExtension(Extension):
def extendMarkdown(self, md, md_globals):
def extendMarkdown(self, md):
md.registerExtension(self)
md.preprocessors.add('emojify',
EmojifyPreprocessor(md),

View File

@ -30,7 +30,7 @@ from markdown.util import etree, AtomicString
class MentionsExtension(Extension):
def extendMarkdown(self, md, md_globals):
def extendMarkdown(self, md):
MENTION_RE = r"(@)([\w.-]+)"
mentionsPattern = MentionsPattern(MENTION_RE)
mentionsPattern.md = md

View File

@ -36,7 +36,7 @@ class TaigaReferencesExtension(Extension):
self.project = project
return super().__init__(*args, **kwargs)
def extendMarkdown(self, md, md_globals):
def extendMarkdown(self, md):
TAIGA_REFERENCE_RE = r'(?<=^|(?<=[^a-zA-Z0-9-\[]))#(\d+)'
referencesPattern = TaigaReferencesPattern(TAIGA_REFERENCE_RE, self.project)
referencesPattern.md = md

View File

@ -28,6 +28,6 @@ class SemiSaneListExtension(markdown.Extension):
newlines.
"""
def extendMarkdown(self, md, md_globals):
def extendMarkdown(self, md):
md.parser.blockprocessors['olist'] = SemiSaneOListProcessor(md.parser)
md.parser.blockprocessors['ulist'] = SemiSaneUListProcessor(md.parser)

View File

@ -31,7 +31,7 @@ class SpacedLinkExtension(markdown.Extension):
extension adds such support.
"""
def extendMarkdown(self, md, md_globals):
def extendMarkdown(self, md):
md.inlinePatterns["link"] = \
markdown.inlinepatterns.LinkPattern(SPACED_LINK_RE, md)
md.inlinePatterns["reference"] = \

View File

@ -14,6 +14,6 @@ class StrikethroughExtension(markdown.Extension):
For example: ``~~strike~~``.
"""
def extendMarkdown(self, md, md_globals):
def extendMarkdown(self, md):
pattern = markdown.inlinepatterns.SimpleTagPattern(STRIKE_RE, 'del')
md.inlinePatterns.add('gfm-strikethrough', pattern, '_end')

View File

@ -28,7 +28,7 @@ from taiga.front.templatetags.functions import resolve
class TargetBlankLinkExtension(markdown.Extension):
"""An extension that add target="_blank" to all external links."""
def extendMarkdown(self, md, md_globals):
def extendMarkdown(self, md):
md.treeprocessors.add("target_blank_links",
TargetBlankLinksTreeprocessor(md),
"<prettify")

View File

@ -34,7 +34,7 @@ class WikiLinkExtension(Extension):
self.project = project
return super().__init__(*args, **kwargs)
def extendMarkdown(self, md, md_globals):
def extendMarkdown(self, md):
WIKILINK_RE = r"\[\[([\w0-9_ -]+)(\|[^\]]+)?\]\]"
md.inlinePatterns.add("wikilinks",
WikiLinksPattern(md, WIKILINK_RE, self.project),

View File

@ -45,6 +45,7 @@ from taiga.projects.epics.models import Epic
from taiga.projects.history.mixins import HistoryResourceMixin
from taiga.projects.issues.models import Issue
from taiga.projects.likes.mixins.viewsets import LikedResourceMixin, FansViewSetMixin
from taiga.projects.notifications.apps import signal_members_added
from taiga.projects.notifications.mixins import WatchersViewSetMixin
from taiga.projects.notifications.choices import NotifyLevel
from taiga.projects.mixins.on_destroy import MoveOnDestroyMixin
@ -112,8 +113,8 @@ class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin,
qs = project_utils.attach_notify_policies(qs)
qs = project_utils.attach_is_fan(qs, user=self.request.user)
qs = project_utils.attach_my_role_permissions(qs, user=self.request.user)
qs = project_utils.attach_my_role_permissions(qs, user=self.request.user)
qs = project_utils.attach_closed_milestones(qs)
qs = project_utils.attach_my_homepage(qs, user=self.request.user)
else:
qs = project_utils.attach_extra_info(qs, user=self.request.user)
@ -980,6 +981,10 @@ class MembershipViewSet(BlockedByProjectMixin, ModelCrudViewSet):
invitation_extra_text=invitation_extra_text,
callback=self.post_save,
precall=self.pre_save)
signal_members_added.send(sender=self.__class__,
user=self.request.user,
project=project,
new_members=members)
except exc.ValidationError as err:
return response.BadRequest(err.message_dict)

View File

@ -53,17 +53,17 @@ def connect_memberships_signals():
sender=apps.get_model("projects", "Membership"),
dispatch_uid='membership_pre_delete')
# On membership object is deleted, update notify policies of all objects relation.
signals.post_save.connect(handlers.create_notify_policy,
# On membership object is created, reorder and create notify policies
signals.post_save.connect(handlers.membership_post_save,
sender=apps.get_model("projects", "Membership"),
dispatch_uid='create-notify-policy')
dispatch_uid='membership_post_save')
def disconnect_memberships_signals():
signals.pre_delete.disconnect(sender=apps.get_model("projects", "Membership"),
dispatch_uid='membership_pre_delete')
signals.post_save.disconnect(sender=apps.get_model("projects", "Membership"),
dispatch_uid='create-notify-policy')
dispatch_uid='membership_post_save')
## US Statuses Signals

View File

@ -24,11 +24,17 @@ MULTILINE_TYPE = "multiline"
RICHTEXT_TYPE = "richtext"
DATE_TYPE = "date"
URL_TYPE = "url"
DROPDOWN_TYPE = "dropdown"
CHECKBOX_TYPE = "checkbox"
NUMBER_TYPE = "number"
TYPES_CHOICES = (
(TEXT_TYPE, _("Text")),
(MULTILINE_TYPE, _("Multi-Line Text")),
(RICHTEXT_TYPE, _("Rich text")),
(DATE_TYPE, _("Date")),
(URL_TYPE, _("Url"))
(URL_TYPE, _("Url")),
(DROPDOWN_TYPE, _("Dropdown")),
(CHECKBOX_TYPE, _("Checkbox")),
(NUMBER_TYPE, _("Number")),
)

View File

@ -0,0 +1,57 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.2 on 2018-10-22 16:24
from __future__ import unicode_literals
import django.core.serializers.json
from django.db import migrations, models
import taiga.base.db.models.fields.json
class Migration(migrations.Migration):
dependencies = [
('custom_attributes', '0012_auto_20161201_1628'),
]
operations = [
migrations.AddField(
model_name='epiccustomattribute',
name='extra',
field=taiga.base.db.models.fields.json.JSONField(blank=True, default=None, encoder=django.core.serializers.json.DjangoJSONEncoder, null=True),
),
migrations.AddField(
model_name='issuecustomattribute',
name='extra',
field=taiga.base.db.models.fields.json.JSONField(blank=True, default=None, encoder=django.core.serializers.json.DjangoJSONEncoder, null=True),
),
migrations.AddField(
model_name='taskcustomattribute',
name='extra',
field=taiga.base.db.models.fields.json.JSONField(blank=True, default=None, encoder=django.core.serializers.json.DjangoJSONEncoder, null=True),
),
migrations.AddField(
model_name='userstorycustomattribute',
name='extra',
field=taiga.base.db.models.fields.json.JSONField(blank=True, default=None, encoder=django.core.serializers.json.DjangoJSONEncoder, null=True),
),
migrations.AlterField(
model_name='epiccustomattribute',
name='type',
field=models.CharField(choices=[('text', 'Text'), ('multiline', 'Multi-Line Text'), ('richtext', 'Rich text'), ('date', 'Date'), ('url', 'Url'), ('dropdown', 'Dropdown')], default='text', max_length=16, verbose_name='type'),
),
migrations.AlterField(
model_name='issuecustomattribute',
name='type',
field=models.CharField(choices=[('text', 'Text'), ('multiline', 'Multi-Line Text'), ('richtext', 'Rich text'), ('date', 'Date'), ('url', 'Url'), ('dropdown', 'Dropdown')], default='text', max_length=16, verbose_name='type'),
),
migrations.AlterField(
model_name='taskcustomattribute',
name='type',
field=models.CharField(choices=[('text', 'Text'), ('multiline', 'Multi-Line Text'), ('richtext', 'Rich text'), ('date', 'Date'), ('url', 'Url'), ('dropdown', 'Dropdown')], default='text', max_length=16, verbose_name='type'),
),
migrations.AlterField(
model_name='userstorycustomattribute',
name='type',
field=models.CharField(choices=[('text', 'Text'), ('multiline', 'Multi-Line Text'), ('richtext', 'Rich text'), ('date', 'Date'), ('url', 'Url'), ('dropdown', 'Dropdown')], default='text', max_length=16, verbose_name='type'),
),
]

View File

@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.2 on 2018-10-25 07:11
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('custom_attributes', '0013_auto_20181022_1624'),
]
operations = [
migrations.AlterField(
model_name='epiccustomattribute',
name='type',
field=models.CharField(choices=[('text', 'Text'), ('multiline', 'Multi-Line Text'), ('richtext', 'Rich text'), ('date', 'Date'), ('url', 'Url'), ('dropdown', 'Dropdown'), ('checkbox', 'Checkbox'), ('number', 'Number')], default='text', max_length=16, verbose_name='type'),
),
migrations.AlterField(
model_name='issuecustomattribute',
name='type',
field=models.CharField(choices=[('text', 'Text'), ('multiline', 'Multi-Line Text'), ('richtext', 'Rich text'), ('date', 'Date'), ('url', 'Url'), ('dropdown', 'Dropdown'), ('checkbox', 'Checkbox'), ('number', 'Number')], default='text', max_length=16, verbose_name='type'),
),
migrations.AlterField(
model_name='taskcustomattribute',
name='type',
field=models.CharField(choices=[('text', 'Text'), ('multiline', 'Multi-Line Text'), ('richtext', 'Rich text'), ('date', 'Date'), ('url', 'Url'), ('dropdown', 'Dropdown'), ('checkbox', 'Checkbox'), ('number', 'Number')], default='text', max_length=16, verbose_name='type'),
),
migrations.AlterField(
model_name='userstorycustomattribute',
name='type',
field=models.CharField(choices=[('text', 'Text'), ('multiline', 'Multi-Line Text'), ('richtext', 'Rich text'), ('date', 'Date'), ('url', 'Url'), ('dropdown', 'Dropdown'), ('checkbox', 'Checkbox'), ('number', 'Number')], default='text', max_length=16, verbose_name='type'),
),
]

View File

@ -41,7 +41,7 @@ class AbstractCustomAttribute(models.Model):
order = models.BigIntegerField(null=False, blank=False, default=timestamp_ms, verbose_name=_("order"))
project = models.ForeignKey("projects.Project", null=False, blank=False, related_name="%(class)ss",
verbose_name=_("project"))
extra = JSONField(blank=True, default=None, null=True)
created_date = models.DateTimeField(null=False, blank=False, default=timezone.now,
verbose_name=_("created date"))
modified_date = models.DateTimeField(null=False, blank=False,

View File

@ -32,6 +32,7 @@ class BaseCustomAttributeSerializer(serializers.LightSerializer):
type = Field()
order = Field()
project = Field(attr="project_id")
extra = Field()
created_date = Field()
modified_date = Field()

View File

@ -24,6 +24,8 @@ from taiga.base import response
from taiga.base.decorators import detail_route
from taiga.base.api import ReadOnlyListViewSet
from taiga.mdrender.service import render as mdrender
from taiga.projects.notifications import services as notifications_services
from taiga.projects.notifications.apps import signal_mentions
from . import permissions
from . import serializers
@ -57,6 +59,11 @@ class HistoryViewSet(ReadOnlyListViewSet):
return response.Ok(serializer.data)
def _get_new_mentions(self, obj: object, old_comment: str, new_comment: str):
old_mentions = notifications_services.get_mentions(obj.project, old_comment)
submitted_mentions = notifications_services.get_mentions(obj, new_comment)
return list(set(submitted_mentions) - set(old_mentions))
@detail_route(methods=['get'])
def comment_versions(self, request, pk):
obj = self.get_object()
@ -106,11 +113,20 @@ class HistoryViewSet(ReadOnlyListViewSet):
}
})
new_mentions = self._get_new_mentions(obj, history_entry.comment, comment)
history_entry.edit_comment_date = timezone.now()
history_entry.comment = comment
history_entry.comment_html = mdrender(obj.project, comment)
history_entry.comment_versions = comment_versions
history_entry.save()
if new_mentions:
signal_mentions.send(sender=self.__class__,
user=self.request.user,
obj=obj,
mentions=new_mentions)
return response.Ok()
@detail_route(methods=['post'])
@ -165,6 +181,17 @@ class HistoryViewSet(ReadOnlyListViewSet):
self.check_permissions(request, "retrieve", obj)
qs = services.get_history_queryset_by_model_instance(obj)
qs = services.prefetch_owners_in_history_queryset(qs)
history_type = self.request.GET.get('type')
if history_type == 'activity':
qs = qs.filter(diff__isnull=False, comment__exact='').exclude(diff__exact='')
if self.request.GET.get(self.page_kwarg):
qs = qs.order_by("-created_at")
page = self.paginate_queryset(qs)
serializer = self.get_pagination_serializer(page)
return response.Ok(serializer.data)
return self.response_for_queryset(qs)

View File

@ -30,7 +30,7 @@ from .choices import HistoryType
from .choices import HISTORY_TYPE_CHOICES
from taiga.base.utils.diff import make_diff as make_diff_from_dicts
from taiga.projects.custom_attributes.choices import TEXT_TYPE
from taiga.projects.custom_attributes.choices import CHECKBOX_TYPE, NUMBER_TYPE, TEXT_TYPE
# This keys has been removed from freeze_impl so we can have objects where the
# previous diff has value for the attribute and we want to prevent their propagation
@ -262,13 +262,19 @@ class HistoryEntry(models.Model):
if aid in oldcustattrs and aid in newcustattrs:
changes = make_diff_from_dicts(oldcustattrs[aid], newcustattrs[aid],
excluded_keys=("name"))
newcustattr = newcustattrs.get(aid, {})
if changes:
change_type = newcustattr.get("type", TEXT_TYPE)
old_value = oldcustattrs[aid].get("value", "")
new_value = newcustattrs[aid].get("value", "")
value_diff = get_diff_of_htmls(old_value, new_value)
if change_type in [NUMBER_TYPE, CHECKBOX_TYPE]:
old_value = oldcustattrs[aid].get("value")
new_value = newcustattrs[aid].get("value")
value_diff = [old_value, new_value]
else:
old_value = oldcustattrs[aid].get("value", "")
new_value = newcustattrs[aid].get("value", "")
value_diff = get_diff_of_htmls(old_value,
new_value)
change = {
"name": newcustattr.get("name", ""),
"changes": changes,
@ -279,8 +285,15 @@ class HistoryEntry(models.Model):
elif aid in oldcustattrs and aid not in newcustattrs:
custom_attributes["deleted"].append(oldcustattrs[aid])
elif aid not in oldcustattrs and aid in newcustattrs:
new_value = newcustattrs[aid].get("value", "")
value_diff = get_diff_of_htmls("", new_value)
newcustattr = newcustattrs.get(aid, {})
change_type = newcustattr.get("type", TEXT_TYPE)
if change_type in [NUMBER_TYPE, CHECKBOX_TYPE]:
old_value = None
new_value = newcustattrs[aid].get("value")
value_diff = [old_value, new_value]
else:
new_value = newcustattrs[aid].get("value", "")
value_diff = get_diff_of_htmls("", new_value)
newcustattrs[aid]["value_diff"] = value_diff
custom_attributes["new"].append(newcustattrs[aid])

View File

@ -28,9 +28,12 @@ from taiga.base.api.mixins import BlockedByProjectMixin
from taiga.base.api.utils import get_object_or_404
from taiga.projects.history.mixins import HistoryResourceMixin
from taiga.projects.milestones.models import Milestone
from taiga.projects.mixins.by_ref import ByRefMixin
from taiga.projects.models import Project, IssueStatus, Severity, Priority, IssueType
from taiga.projects.notifications.mixins import WatchedResourceMixin, WatchersViewSetMixin
from taiga.projects.notifications.mixins import AssignedToSignalMixin
from taiga.projects.notifications.mixins import WatchedResourceMixin
from taiga.projects.notifications.mixins import WatchersViewSetMixin
from taiga.projects.occ import OCCResourceMixin
from taiga.projects.tagging.api import TaggedResourceMixin
from taiga.projects.votes.mixins.viewsets import VotedResourceMixin, VotersViewSetMixin
@ -44,8 +47,10 @@ from . import serializers
from . import validators
class IssueViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, WatchedResourceMixin,
ByRefMixin, TaggedResourceMixin, BlockedByProjectMixin, ModelCrudViewSet):
class IssueViewSet(AssignedToSignalMixin, OCCResourceMixin, VotedResourceMixin,
HistoryResourceMixin, WatchedResourceMixin, ByRefMixin,
TaggedResourceMixin, BlockedByProjectMixin,
ModelCrudViewSet):
validator_class = validators.IssueValidator
queryset = models.Issue.objects.all()
permission_classes = (permissions.IssuePermission, )
@ -248,6 +253,22 @@ class IssueViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, W
return response.BadRequest(validator.errors)
@list_route(methods=["POST"])
def bulk_update_milestone(self, request, **kwargs):
validator = validators.UpdateMilestoneBulkValidator(data=request.DATA)
if not validator.is_valid():
return response.BadRequest(validator.errors)
data = validator.data
project = get_object_or_404(Project, pk=data["project_id"])
milestone = get_object_or_404(Milestone, pk=data["milestone_id"])
self.check_permissions(request, "bulk_update_milestone", project)
ret = services.update_issues_milestone_in_bulk(data["bulk_issues"], milestone)
return response.Ok(ret)
class IssueVotersViewSet(VotersViewSetMixin, ModelListViewSet):
permission_classes = (permissions.IssueVotersPermission,)

View File

@ -25,6 +25,11 @@ def connect_issues_signals():
from taiga.projects.tagging import signals as tagging_handlers
from . import signals as handlers
# Cached prev object version
signals.pre_save.connect(handlers.cached_prev_issue,
sender=apps.get_model("issues", "Issue"),
dispatch_uid="cached_prev_issue")
# Finished date
signals.pre_save.connect(handlers.set_finished_date_when_edit_issue,
sender=apps.get_model("issues", "Issue"),
@ -35,6 +40,14 @@ def connect_issues_signals():
sender=apps.get_model("issues", "Issue"),
dispatch_uid="tags_normalization_issue")
# Open/Close US and Milestone
signals.post_save.connect(handlers.try_to_close_or_open_milestone_when_create_or_edit_issue,
sender=apps.get_model("issues", "Issue"),
dispatch_uid="try_to_close_or_open_milestone_when_create_or_edit_issue")
signals.post_delete.connect(handlers.try_to_close_milestone_when_delete_issue,
sender=apps.get_model("issues", "Issue"),
dispatch_uid="try_to_close_milestone_when_delete_issue")
def connect_issues_custom_attributes_signals():
from taiga.projects.custom_attributes import signals as custom_attributes_handlers
@ -50,11 +63,19 @@ def connect_all_issues_signals():
def disconnect_issues_signals():
signals.pre_save.disconnect(sender=apps.get_model("issues", "Issue"),
dispatch_uid="cached_prev_issue")
signals.pre_save.disconnect(sender=apps.get_model("issues", "Issue"),
dispatch_uid="set_finished_date_when_edit_issue")
signals.pre_save.disconnect(sender=apps.get_model("issues", "Issue"),
dispatch_uid="tags_normalization_issue")
signals.post_save.disconnect(sender=apps.get_model("issues", "Issue"),
dispatch_uid="try_to_close_or_open_milestone_when_create_or_edit_issue")
signals.post_delete.disconnect(sender=apps.get_model("issues", "Issue"),
dispatch_uid="try_to_close_milestone_when_delete_issue")
def disconnect_issues_custom_attributes_signals():
signals.post_save.disconnect(sender=apps.get_model("issues", "Issue"),

View File

@ -35,6 +35,7 @@ class IssuePermission(TaigaResourcePermission):
filters_data_perms = AllowAny()
csv_perms = AllowAny()
bulk_create_perms = HasProjectPerm('add_issue')
bulk_update_milestone_perms = HasProjectPerm('modify_issue')
delete_comment_perms= HasProjectPerm('modify_issue')
upvote_perms = IsAuthenticated() & HasProjectPerm('view_issues')
downvote_perms = IsAuthenticated() & HasProjectPerm('view_issues')

View File

@ -26,6 +26,9 @@ from django.db import connection
from django.utils.translation import ugettext as _
from taiga.base.utils import db, text
from taiga.events import events
from taiga.projects.history.services import take_snapshot
from taiga.projects.issues.apps import (
connect_issues_signals,
disconnect_issues_signals)
@ -72,10 +75,38 @@ def create_issues_in_bulk(bulk_data, callback=None, precall=None, **additional_f
return issues
def snapshot_issues_in_bulk(bulk_data, user):
for issue_data in bulk_data:
try:
issue = models.Issue.objects.get(pk=issue_data['issue_id'])
take_snapshot(issue, user=user)
except models.Issue.DoesNotExist:
pass
def update_issues_milestone_in_bulk(bulk_data: list, milestone: object):
"""
Update the milestone some issues adding
`bulk_data` should be a list of dicts with the following format:
[{'task_id': <value>}, ...]
"""
issue_milestones = {e["issue_id"]: milestone.id for e in bulk_data}
issue_ids = issue_milestones.keys()
events.emit_event_for_ids(ids=issue_ids,
content_type="issues.issues",
projectid=milestone.project.pk)
db.update_attr_in_bulk_for_ids(issue_milestones, "milestone_id",
model=models.Issue)
return issue_milestones
#####################################################
# CSV
#####################################################
def issues_to_csv(project, queryset):
csv_data = io.StringIO()
fieldnames = ["id", "ref", "subject", "description", "sprint_id", "sprint",

View File

@ -16,9 +16,22 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from contextlib import suppress
from django.core.exceptions import ObjectDoesNotExist
from django.utils import timezone
####################################
# Signals for cached prev task
####################################
# Define the previous version of the task for use it on the post_save handler
def cached_prev_issue(sender, instance, **kwargs):
instance.prev = None
if instance.id:
instance.prev = sender.objects.get(id=instance.id)
####################################
# Signals for set finished date
####################################
@ -30,3 +43,48 @@ def set_finished_date_when_edit_issue(sender, instance, **kwargs):
instance.finished_date = timezone.now()
elif not instance.status.is_closed and instance.finished_date:
instance.finished_date = None
def try_to_close_or_open_milestone_when_create_or_edit_issue(sender, instance, created, **kwargs):
if instance._importing:
return
_try_to_close_or_open_milestone_when_create_or_edit_issue(instance)
def try_to_close_milestone_when_delete_issue(sender, instance, **kwargs):
if instance._importing:
return
_try_to_close_milestone_when_delete_issue(instance)
# Milestone
def _try_to_close_or_open_milestone_when_create_or_edit_issue(instance):
if instance._importing:
return
from taiga.projects.milestones import services as milestone_service
if instance.milestone_id:
if milestone_service.calculate_milestone_is_closed(instance.milestone):
milestone_service.close_milestone(instance.milestone)
else:
milestone_service.open_milestone(instance.milestone)
if instance.prev and instance.prev.milestone_id and instance.prev.milestone_id != instance.milestone_id:
if milestone_service.calculate_milestone_is_closed(instance.prev.milestone):
milestone_service.close_milestone(instance.prev.milestone)
else:
milestone_service.open_milestone(instance.prev.milestone)
def _try_to_close_milestone_when_delete_issue(instance):
if instance._importing:
return
from taiga.projects.milestones import services as milestone_service
with suppress(ObjectDoesNotExist):
if instance.milestone_id and milestone_service.calculate_milestone_is_closed(instance.milestone):
milestone_service.close_milestone(instance.milestone)

View File

@ -15,10 +15,13 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.utils.translation import ugettext as _
from taiga.base.api import serializers
from taiga.base.api import validators
from taiga.base.exceptions import ValidationError
from taiga.base.fields import PgArrayField
from taiga.projects.milestones.models import Milestone
from taiga.projects.mixins.validators import AssignedToValidator
from taiga.projects.notifications.mixins import EditableWatchedResourceSerializer
from taiga.projects.notifications.validators import WatchersValidator
@ -43,3 +46,35 @@ class IssuesBulkValidator(ProjectExistsValidator, validators.Validator):
project_id = serializers.IntegerField()
milestone_id = serializers.IntegerField(required=False)
bulk_issues = serializers.CharField()
# Milestone bulk validators
class _IssueMilestoneBulkValidator(validators.Validator):
issue_id = serializers.IntegerField()
class UpdateMilestoneBulkValidator(ProjectExistsValidator, validators.Validator):
project_id = serializers.IntegerField()
milestone_id = serializers.IntegerField()
bulk_issues = _IssueMilestoneBulkValidator(many=True)
def validate_milestone_id(self, attrs, source):
filters = {
"project__id": attrs["project_id"],
"id": attrs[source]
}
if not Milestone.objects.filter(**filters).exists():
raise ValidationError(_("The milestone isn't valid for the project"))
return attrs
def validate_bulk_tasks(self, attrs, source):
filters = {
"project__id": attrs["project_id"],
"id__in": [issue["issue_id"] for issue in attrs[source]]
}
if models.Issue.objects.filter(**filters).count() != len(filters["id__in"]):
raise ValidationError(_("All the issues must be from the same project"))
return attrs

View File

@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.2 on 2018-09-18 13:55
from __future__ import unicode_literals
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('projects', '0060_auto_20180614_1338'),
]
operations = [
migrations.AlterUniqueTogether(
name='issuestatus',
unique_together=set([('project', 'name'), ('project', 'slug')]),
),
]

View File

@ -27,11 +27,17 @@ from taiga.base.api.mixins import BlockedByProjectMixin
from taiga.base.api.utils import get_object_or_404
from taiga.base.utils.db import get_object_or_none
from taiga.projects.models import Project
from taiga.projects.notifications.mixins import WatchedResourceMixin
from taiga.projects.notifications.mixins import WatchersViewSetMixin
from taiga.projects.history.mixins import HistoryResourceMixin
from taiga.projects.tasks.validators import UpdateMilestoneBulkValidator as \
TasksUpdateMilestoneValidator
from taiga.projects.issues.validators import UpdateMilestoneBulkValidator as \
IssuesUpdateMilestoneValidator
from . import serializers
from . import services
from . import validators
from . import models
from . import permissions
@ -142,6 +148,69 @@ class MilestoneViewSet(HistoryResourceMixin, WatchedResourceMixin,
return response.Ok(milestone_stats)
@detail_route(methods=["POST"])
def move_userstories_to_sprint(self, request, pk=None, **kwargs):
milestone = get_object_or_404(models.Milestone, pk=pk)
self.check_permissions(request, "move_related_items", milestone)
validator = validators.UpdateMilestoneBulkValidator(data=request.DATA)
if not validator.is_valid():
return response.BadRequest(validator.errors)
data = validator.data
project = get_object_or_404(Project, pk=data["project_id"])
milestone_result = get_object_or_404(models.Milestone, pk=data["milestone_id"])
if data["bulk_stories"]:
self.check_permissions(request, "move_uss_to_sprint", project)
services.update_userstories_milestone_in_bulk(data["bulk_stories"], milestone_result)
services.snapshot_userstories_in_bulk(data["bulk_stories"], request.user)
return response.NoContent()
@detail_route(methods=["POST"])
def move_tasks_to_sprint(self, request, pk=None, **kwargs):
milestone = get_object_or_404(models.Milestone, pk=pk)
self.check_permissions(request, "move_related_items", milestone)
validator = TasksUpdateMilestoneValidator(data=request.DATA)
if not validator.is_valid():
return response.BadRequest(validator.errors)
data = validator.data
project = get_object_or_404(Project, pk=data["project_id"])
milestone_result = get_object_or_404(models.Milestone, pk=data["milestone_id"])
if data["bulk_tasks"]:
self.check_permissions(request, "move_tasks_to_sprint", project)
services.update_tasks_milestone_in_bulk(data["bulk_tasks"], milestone_result)
services.snapshot_tasks_in_bulk(data["bulk_tasks"], request.user)
return response.NoContent()
@detail_route(methods=["POST"])
def move_issues_to_sprint(self, request, pk=None, **kwargs):
milestone = get_object_or_404(models.Milestone, pk=pk)
self.check_permissions(request, "move_related_items", milestone)
validator = IssuesUpdateMilestoneValidator(data=request.DATA)
if not validator.is_valid():
return response.BadRequest(validator.errors)
data = validator.data
project = get_object_or_404(Project, pk=data["project_id"])
milestone_result = get_object_or_404(models.Milestone, pk=data["milestone_id"])
if data["bulk_issues"]:
self.check_permissions(request, "move_issues_to_sprint", project)
services.update_issues_milestone_in_bulk(data["bulk_issues"], milestone_result)
services.snapshot_issues_in_bulk(data["bulk_issues"], request.user)
return response.NoContent()
class MilestoneWatchersViewSet(WatchersViewSetMixin, ModelListViewSet):
permission_classes = (permissions.MilestoneWatchersPermission,)

View File

@ -33,6 +33,11 @@ class MilestonePermission(TaigaResourcePermission):
stats_perms = HasProjectPerm('view_milestones')
watch_perms = IsAuthenticated() & HasProjectPerm('view_milestones')
unwatch_perms = IsAuthenticated() & HasProjectPerm('view_milestones')
move_related_items_perms = HasProjectPerm('modify_milestone')
move_uss_to_sprint_perms = HasProjectPerm('modify_us')
move_tasks_to_sprint_perms = HasProjectPerm('modify_task')
move_issues_to_sprint_perms = HasProjectPerm('modify_issue')
class MilestoneWatchersPermission(TaigaResourcePermission):
enought_perms = IsProjectAdmin() | IsSuperUser()

View File

@ -16,16 +16,29 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.utils import timezone
from . import models
from taiga.base.utils import db
from taiga.events import events
from taiga.projects.history.services import take_snapshot
from taiga.projects.services import apply_order_updates
from taiga.projects.issues.models import Issue
from taiga.projects.tasks.models import Task
from taiga.projects.userstories.models import UserStory
def calculate_milestone_is_closed(milestone):
return (milestone.user_stories.all().count() > 0 and
all([task.status is not None and task.status.is_closed for task in milestone.tasks.all()]) and
all([user_story.is_closed for user_story in milestone.user_stories.all()]))
all_us_closed = all([user_story.is_closed for user_story in milestone.user_stories.all()])
all_tasks_closed = all([task.status is not None and task.status.is_closed for task in
milestone.tasks.all()])
all_issues_closed = all([issue.is_closed for issue in milestone.issues.all()])
uss_check = milestone.user_stories.all().count() > 0 \
and all_tasks_closed and all_us_closed and all_issues_closed
issues_check = milestone.issues.all().count() > 0 and all_issues_closed \
and all_tasks_closed and all_us_closed
tasks_check = milestone.tasks.all().count() > 0 and all_tasks_closed \
and all_issues_closed and all_us_closed
return uss_check or issues_check or tasks_check
def close_milestone(milestone):
@ -38,3 +51,137 @@ def open_milestone(milestone):
if milestone.closed:
milestone.closed = False
milestone.save(update_fields=["closed",])
def update_userstories_milestone_in_bulk(bulk_data: list, milestone: object):
"""
Update the milestone and the milestone order of some user stories adding
the extra orders needed to keep consistency.
`bulk_data` should be a list of dicts with the following format:
[{'us_id': <value>, 'order': <value>}, ...]
"""
user_stories = milestone.user_stories.all()
us_orders = {us.id: getattr(us, "sprint_order") for us in user_stories}
new_us_orders = {}
for e in bulk_data:
new_us_orders[e["us_id"]] = e["order"]
# The base orders where we apply the new orders must containg all
# the values
us_orders[e["us_id"]] = e["order"]
apply_order_updates(us_orders, new_us_orders)
us_milestones = {e["us_id"]: milestone.id for e in bulk_data}
user_story_ids = us_milestones.keys()
events.emit_event_for_ids(ids=user_story_ids,
content_type="userstories.userstory",
projectid=milestone.project.pk)
us_instance_list = []
us_values = []
for us_id in user_story_ids:
us = UserStory.objects.get(pk=us_id)
us_instance_list.append(us)
us_values.append({'milestone_id': milestone.id})
db.update_in_bulk(us_instance_list, us_values)
db.update_attr_in_bulk_for_ids(us_orders, "sprint_order", UserStory)
# Updating the milestone for the tasks
Task.objects.filter(
user_story_id__in=[e["us_id"] for e in bulk_data]).update(
milestone=milestone)
return us_orders
def snapshot_userstories_in_bulk(bulk_data, user):
for us_data in bulk_data:
try:
us = UserStory.objects.get(pk=us_data['us_id'])
take_snapshot(us, user=user)
except UserStory.DoesNotExist:
pass
def update_tasks_milestone_in_bulk(bulk_data: list, milestone: object):
"""
Update the milestone and the milestone order of some tasks adding
the extra orders needed to keep consistency.
`bulk_data` should be a list of dicts with the following format:
[{'task_id': <value>, 'order': <value>}, ...]
"""
tasks = milestone.tasks.all()
task_orders = {task.id: getattr(task, "taskboard_order") for task in tasks}
new_task_orders = {}
for e in bulk_data:
new_task_orders[e["task_id"]] = e["order"]
# The base orders where we apply the new orders must containg all
# the values
task_orders[e["task_id"]] = e["order"]
apply_order_updates(task_orders, new_task_orders)
task_milestones = {e["task_id"]: milestone.id for e in bulk_data}
task_ids = task_milestones.keys()
events.emit_event_for_ids(ids=task_ids,
content_type="tasks.task",
projectid=milestone.project.pk)
task_instance_list = []
task_values = []
for task_id in task_ids:
task = Task.objects.get(pk=task_id)
task_instance_list.append(task)
task_values.append({'milestone_id': milestone.id})
db.update_in_bulk(task_instance_list, task_values)
db.update_attr_in_bulk_for_ids(task_orders, "taskboard_order", Task)
return task_milestones
def snapshot_tasks_in_bulk(bulk_data, user):
for task_data in bulk_data:
try:
task = Task.objects.get(pk=task_data['task_id'])
take_snapshot(task, user=user)
except Task.DoesNotExist:
pass
def update_issues_milestone_in_bulk(bulk_data: list, milestone: object):
"""
Update the milestone some issues adding
`bulk_data` should be a list of dicts with the following format:
[{'task_id': <value>}, ...]
"""
issue_milestones = {e["issue_id"]: milestone.id for e in bulk_data}
issue_ids = issue_milestones.keys()
events.emit_event_for_ids(ids=issue_ids,
content_type="issues.issues",
projectid=milestone.project.pk)
issues_instance_list = []
issues_values = []
for issue_id in issue_ids:
issue = Issue.objects.get(pk=issue_id)
issues_instance_list.append(issue)
issues_values.append({'milestone_id': milestone.id})
db.update_in_bulk(issues_instance_list, issues_values)
return issue_milestones
def snapshot_issues_in_bulk(bulk_data, user):
for issue_data in bulk_data:
try:
issue = Issue.objects.get(pk=issue_data['issue_id'])
take_snapshot(issue, user=user)
except Issue.DoesNotExist:
pass

View File

@ -19,15 +19,17 @@
from django.utils.translation import ugettext as _
from taiga.base.exceptions import ValidationError
from taiga.base.api import serializers
from taiga.base.api import validators
from taiga.projects.validators import DuplicatedNameInProjectValidator
from taiga.projects.notifications.validators import WatchersValidator
from taiga.projects.userstories.models import UserStory
from taiga.projects.validators import DuplicatedNameInProjectValidator
from taiga.projects.validators import ProjectExistsValidator
from . import models
class MilestoneExistsValidator:
def validate_sprint_id(self, attrs, source):
def validate_milestone_id(self, attrs, source):
value = attrs[source]
if not models.Milestone.objects.filter(pk=value).exists():
msg = _("There's no milestone with that id")
@ -39,3 +41,28 @@ class MilestoneValidator(WatchersValidator, DuplicatedNameInProjectValidator, va
class Meta:
model = models.Milestone
read_only_fields = ("id", "created_date", "modified_date")
# bulk validators
class _UserStoryMilestoneBulkValidator(validators.Validator):
us_id = serializers.IntegerField()
order = serializers.IntegerField()
class UpdateMilestoneBulkValidator(MilestoneExistsValidator,
ProjectExistsValidator,
validators.Validator):
project_id = serializers.IntegerField()
milestone_id = serializers.IntegerField()
bulk_stories = _UserStoryMilestoneBulkValidator(many=True)
def validate_bulk_stories(self, attrs, source):
filters = {
"project__id": attrs["project_id"],
"id__in": [us["us_id"] for us in attrs[source]]
}
if UserStory.objects.filter(**filters).count() != len(filters["id__in"]):
raise ValidationError(_("All the user stories must be from the same project"))
return attrs

View File

@ -0,0 +1,16 @@
# -*- coding: utf-8 -*-
# Copyright (C) 2014-2018 Taiga Agile LLC
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
default_app_config = "taiga.projects.notifications.apps.NotificationsAppConfig"

View File

@ -17,8 +17,12 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.db.models import Q
from django.utils import timezone
from taiga.base import response
from taiga.base.api import ModelCrudViewSet
from taiga.base.api import GenericViewSet
from taiga.base.api.utils import get_object_or_404
from taiga.projects.notifications.choices import NotifyLevel
from taiga.projects.models import Project
@ -50,3 +54,53 @@ class NotifyPolicyViewSet(ModelCrudViewSet):
return models.NotifyPolicy.objects.filter(user=self.request.user).filter(
Q(project__owner=self.request.user) | Q(project__memberships__user=self.request.user)
).distinct()
class WebNotificationsViewSet(GenericViewSet):
serializer_class = serializers.WebNotificationSerializer
resource_model = models.WebNotification
def check_permissions(self, request, obj=None):
return obj and request.user.is_authenticated() and \
request.user.pk == obj.user_id
def list(self, request):
if self.request.user.is_anonymous():
return response.Ok({})
queryset = models.WebNotification.objects\
.filter(user=self.request.user)
if request.GET.get("only_unread", False):
queryset = queryset.filter(read__isnull=True)
queryset = queryset.order_by('-read', '-created')
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_pagination_serializer(page)
return response.Ok({
"objects": serializer.data,
"total": queryset.count()
})
serializer = self.get_serializer(queryset, many=True)
return response.Ok(serializer.data)
def patch(self, request, *args, **kwargs):
self.check_permissions(request)
resource_id = kwargs.get("resource_id", None)
resource = get_object_or_404(self.resource_model, pk=resource_id)
resource.read = timezone.now()
resource.save()
return response.Ok({})
def post(self, request):
self.check_permissions(request)
models.WebNotification.objects.filter(user=self.request.user)\
.update(read=timezone.now())
return response.Ok()

View File

@ -0,0 +1,46 @@
# -*- coding: utf-8 -*-
# Copyright (C) 2014-2018 Taiga Agile LLC
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django import dispatch
from django.apps import AppConfig
signal_assigned_to = dispatch.Signal(providing_args=["user", "obj"])
signal_assigned_users = dispatch.Signal(providing_args=["user", "obj",
"new_assigned_users"])
signal_watchers_added = dispatch.Signal(providing_args=["user", "obj",
"new_watchers"])
signal_members_added = dispatch.Signal(providing_args=["user", "project",
"new_members"])
signal_mentions = dispatch.Signal(providing_args=["user", "obj",
"mentions"])
signal_comment = dispatch.Signal(providing_args=["user", "obj",
"watchers"])
signal_comment_mentions = dispatch.Signal(providing_args=["user", "obj",
"mentions"])
class NotificationsAppConfig(AppConfig):
name = "taiga.projects.notifications"
verbose_name = "Notifications"
def ready(self):
from . import signals as handlers
signal_assigned_to.connect(handlers.on_assigned_to)
signal_assigned_users.connect(handlers.on_assigned_users)
signal_watchers_added.connect(handlers.on_watchers_added)
signal_members_added.connect(handlers.on_members_added)
signal_mentions.connect(handlers.on_mentions)
signal_comment.connect(handlers.on_comment)
signal_comment_mentions.connect(handlers.on_comment_mentions)

View File

@ -31,3 +31,22 @@ NOTIFY_LEVEL_CHOICES = (
(NotifyLevel.all, _("All")),
(NotifyLevel.none, _("None")),
)
class WebNotificationType(enum.IntEnum):
assigned = 1
mentioned = 2
added_as_watcher = 3
added_as_member = 4
comment = 5
mentioned_in_comment = 6
WEB_NOTIFICATION_TYPE_CHOICES = (
(WebNotificationType.assigned, _("Assigned")),
(WebNotificationType.mentioned, _("Mentioned")),
(WebNotificationType.added_as_watcher, _("Added as watcher")),
(WebNotificationType.added_as_member, _("Added as member")),
(WebNotificationType.comment, _("Comment")),
(WebNotificationType.mentioned_in_comment, _("Mentioned in comment")),
)

View File

@ -0,0 +1,37 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.2 on 2018-10-10 11:24
from __future__ import unicode_literals
from django.conf import settings
import django.core.serializers.json
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import taiga.base.db.models.fields.json
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('notifications', '0007_notifypolicy_live_notify_level'),
]
operations = [
migrations.CreateModel(
name='WebNotification',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', models.DateTimeField(db_index=True, default=django.utils.timezone.now)),
('read', models.DateTimeField(default=None, null=True)),
('event_type', models.PositiveIntegerField()),
('data', taiga.base.db.models.fields.json.JSONField(encoder=django.core.serializers.json.DjangoJSONEncoder)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='web_notifications', to=settings.AUTH_USER_MODEL)),
],
),
migrations.AddField(
model_name='notifypolicy',
name='web_notify_level',
field=models.BooleanField(default=True),
),
]

View File

@ -29,6 +29,12 @@ from taiga.base.api.utils import get_object_or_404
from taiga.base.fields import WatchersField, MethodField
from taiga.projects.notifications import services
from . apps import signal_assigned_to
from . apps import signal_assigned_users
from . apps import signal_comment
from . apps import signal_comment_mentions
from . apps import signal_mentions
from . apps import signal_watchers_added
from . serializers import WatcherSerializer
@ -47,6 +53,8 @@ class WatchedResourceMixin:
"""
_not_notify = False
_old_watchers = None
_old_mentions = []
@detail_route(methods=["POST"])
def watch(self, request, pk=None):
@ -86,13 +94,38 @@ class WatchedResourceMixin:
# some text fields for extract mentions and add them
# to watchers before obtain a complete list of
# notifiable users.
services.analize_object_for_watchers(obj, history.comment, history.owner)
services.analize_object_for_watchers(obj, history.comment,
history.owner)
# Get a complete list of notifiable users for current
# object and send the change notification to them.
services.send_notifications(obj, history=history)
def update(self, request, *args, **kwargs):
obj = self.get_object_or_none()
if obj and obj.id:
if hasattr(obj, "watchers"):
self._old_watchers = [
watcher.id for watcher in self.get_object().get_watchers()
]
mention_fields = ['description', 'content']
for field_name in mention_fields:
old_mentions = self._get_old_mentions_in_field(obj, field_name)
if not len(old_mentions):
continue
self._old_mentions = old_mentions
return super().update(request, *args, **kwargs)
def post_save(self, obj, created=False):
self.create_web_notifications_for_added_watchers(obj)
self.create_web_notifications_for_mentioned_users(obj)
mentions = self.create_web_notifications_for_mentions_in_comments(obj)
exclude = mentions + [self.request.user.id]
self.create_web_notifications_for_comment(obj, exclude)
self.send_notifications(obj)
super().post_save(obj, created)
@ -100,6 +133,84 @@ class WatchedResourceMixin:
self.send_notifications(obj)
super().pre_delete(obj)
def create_web_notifications_for_comment(self, obj, exclude: list=None):
if "comment" in self.request.DATA:
watchers = [
watcher_id for watcher_id in obj.watchers
if watcher_id not in exclude
]
signal_comment.send(sender=self.__class__,
user=self.request.user,
obj=obj,
watchers=watchers)
def create_web_notifications_for_added_watchers(self, obj):
if not hasattr(obj, "watchers"):
return
new_watchers = [
watcher_id for watcher_id in obj.watchers
if watcher_id not in self._old_watchers
and watcher_id != self.request.user.id
]
signal_watchers_added.send(sender=self.__class__,
user=self.request.user,
obj=obj,
new_watchers=new_watchers)
def create_web_notifications_for_mentioned_users(self, obj):
"""
Detect and notify mentioned users
"""
submitted_mentions = self._get_submitted_mentions(obj)
new_mentions = list(set(submitted_mentions) - set(self._old_mentions))
if new_mentions:
signal_mentions.send(sender=self.__class__,
user=self.request.user,
obj=obj,
mentions=new_mentions)
def create_web_notifications_for_mentions_in_comments(self, obj):
"""
Detect and notify mentioned users
"""
new_mentions_in_comment = self._get_mentions_in_comment(obj)
if new_mentions_in_comment:
signal_comment_mentions.send(sender=self.__class__,
user=self.request.user,
obj=obj,
mentions=new_mentions_in_comment)
return [user.id for user in new_mentions_in_comment]
def _get_submitted_mentions(self, obj):
mention_fields = ['description', 'content']
for field_name in mention_fields:
new_mentions = self._get_new_mentions_in_field(obj, field_name)
if len(new_mentions) > 0:
return new_mentions
return []
def _get_mentions_in_comment(self, obj):
comment = self.request.DATA.get('comment')
if comment:
return services.get_mentions(obj, comment)
return []
def _get_old_mentions_in_field(self, obj, field_name):
if not hasattr(obj, field_name):
return []
return services.get_mentions(obj, getattr(obj, field_name))
def _get_new_mentions_in_field(self, obj, field_name):
value = self.request.DATA.get(field_name)
if not value:
return []
return services.get_mentions(obj, value)
class WatchedModelMixin(object):
"""
@ -274,3 +385,47 @@ class WatchersViewSetMixin:
def get_queryset(self):
resource = self.resource_model.objects.get(pk=self.kwargs.get("resource_id"))
return resource.get_watchers()
class AssignedToSignalMixin:
_old_assigned_to = None
def pre_save(self, obj):
if obj.id:
self._old_assigned_to = self.get_object().assigned_to
super().pre_save(obj)
def post_save(self, obj, created=False):
if obj.assigned_to and obj.assigned_to != self._old_assigned_to \
and self.request.user != obj.assigned_to:
signal_assigned_to.send(sender=self.__class__,
user=self.request.user,
obj=obj)
super().post_save(obj, created)
class AssignedUsersSignalMixin:
_old_assigned_users = None
def update(self, request, *args, **kwargs):
obj = self.get_object_or_none()
if hasattr(obj, "assigned_users") and obj.id:
self._old_assigned_users = [
user for user in obj.assigned_users.all()
].copy()
result = super().update(request, *args, **kwargs)
if result and obj.assigned_users:
new_assigned_users = [
user for user in obj.assigned_users.all()
if user not in self._old_assigned_users
and user != self.request.user
]
signal_assigned_users.send(sender=self.__class__,
user=self.request.user,
obj=obj,
new_assigned_users=new_assigned_users)
return result

View File

@ -23,6 +23,7 @@ from django.db import models
from django.utils.translation import ugettext_lazy as _
from django.utils import timezone
from taiga.base.db.models.fields import JSONField
from taiga.projects.history.choices import HISTORY_TYPE_CHOICES
from .choices import NOTIFY_LEVEL_CHOICES, NotifyLevel
@ -37,6 +38,7 @@ class NotifyPolicy(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name="notify_policies")
notify_level = models.SmallIntegerField(choices=NOTIFY_LEVEL_CHOICES)
live_notify_level = models.SmallIntegerField(choices=NOTIFY_LEVEL_CHOICES, default=NotifyLevel.involved)
web_notify_level = models.BooleanField(default=True, null=False, blank=True)
created_at = models.DateTimeField(default=timezone.now)
modified_at = models.DateTimeField()
@ -94,3 +96,11 @@ class Watched(models.Model):
verbose_name = _("Watched")
verbose_name_plural = _("Watched")
unique_together = ("content_type", "object_id", "user", "project")
class WebNotification(models.Model):
created = models.DateTimeField(default=timezone.now, db_index=True)
read = models.DateTimeField(default=None, null=True)
user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name="web_notifications")
event_type = models.PositiveIntegerField()
data = JSONField()

View File

@ -16,8 +16,13 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.contrib.contenttypes.models import ContentType
from taiga.base.api import serializers
from taiga.base.fields import Field, DateTimeField, MethodField
from taiga.users.gravatar import get_user_gravatar_id
from taiga.users.models import get_user_model_safe
from taiga.users.services import get_user_photo_url, get_user_big_photo_url
from . import models
@ -27,7 +32,8 @@ class NotifyPolicySerializer(serializers.ModelSerializer):
class Meta:
model = models.NotifyPolicy
fields = ('id', 'project', 'project_name', 'notify_level', "live_notify_level")
fields = ('id', 'project', 'project_name', 'notify_level',
'live_notify_level', 'web_notify_level')
def get_project_name(self, obj):
return obj.project.name
@ -39,3 +45,67 @@ class WatcherSerializer(serializers.ModelSerializer):
class Meta:
model = get_user_model_safe()
fields = ('id', 'username', 'full_name')
class WebNotificationSerializer(serializers.ModelSerializer):
class Meta:
model = models.WebNotification
fields = ('id', 'event_type', 'user', 'data', 'created', 'read')
class ProjectSerializer(serializers.LightSerializer):
id = Field()
slug = Field()
name = Field()
class ObjectSerializer(serializers.LightSerializer):
id = Field()
ref = MethodField()
subject = MethodField()
content_type = MethodField()
def get_ref(self, obj):
return obj.ref if hasattr(obj, 'ref') else None
def get_subject(self, obj):
return obj.subject if hasattr(obj, 'subject') else None
def get_content_type(self, obj):
content_type = ContentType.objects.get_for_model(obj)
return content_type.model if content_type else None
class UserSerializer(serializers.LightSerializer):
id = Field()
name = MethodField()
photo = MethodField()
big_photo = MethodField()
gravatar_id = MethodField()
username = Field()
is_profile_visible = MethodField()
date_joined = DateTimeField()
def get_name(self, obj):
return obj.get_full_name()
def get_photo(self, obj):
return get_user_photo_url(obj)
def get_big_photo(self, obj):
return get_user_big_photo_url(obj)
def get_gravatar_id(self, obj):
return get_user_gravatar_id(obj)
def get_is_profile_visible(self, obj):
return obj.is_active and not obj.is_system
class NotificationDataSerializer(serializers.LightDictSerializer):
project = ProjectSerializer()
user = UserSerializer()
class ObjectNotificationSerializer(NotificationDataSerializer):
obj = ObjectSerializer()

View File

@ -44,6 +44,10 @@ from .models import HistoryChangeNotification, Watched
from .squashing import squash_history_entries
def remove_lr_cr(s):
return s.replace("\n", "").replace("\r", "")
def notify_policy_exists(project, user) -> bool:
"""
Check if policy exists for specified project
@ -73,7 +77,8 @@ def create_notify_policy(project, user, level=NotifyLevel.involved,
def create_notify_policy_if_not_exists(project, user,
level=NotifyLevel.involved,
live_level=NotifyLevel.involved):
live_level=NotifyLevel.involved,
web_level=True):
"""
Given a project and user, create notification policy for it.
"""
@ -82,7 +87,11 @@ def create_notify_policy_if_not_exists(project, user,
result = model_cls.objects.get_or_create(
project=project,
user=user,
defaults={"notify_level": level, "live_notify_level": live_level}
defaults={
"notify_level": level,
"live_notify_level": live_level,
"web_notify_level": web_level
}
)
return result[0]
except IntegrityError as e:
@ -95,27 +104,39 @@ def analize_object_for_watchers(obj: object, comment: str, user: object):
Generic implementation for analize model objects and
extract mentions from it and add it to watchers.
"""
if not hasattr(obj, "get_project"):
if not hasattr(obj, "add_watcher"):
return
if not hasattr(obj, "add_watcher"):
mentions = get_object_mentions(obj, comment)
if mentions:
for user in mentions:
obj.add_watcher(user)
# Adding the person who edited the object to the watchers
if comment and not user.is_system:
obj.add_watcher(user)
def get_object_mentions(obj: object, comment: str):
"""
Generic implementation for analize model objects and
extract mentions from it.
"""
if not hasattr(obj, "get_project"):
return
texts = (getattr(obj, "description", ""),
getattr(obj, "content", ""),
comment,)
return get_mentions(obj.get_project(), "\n".join(texts))
def get_mentions(project: object, text: str):
from taiga.mdrender.service import render_and_extract
_, data = render_and_extract(obj.get_project(), "\n".join(texts))
_, data = render_and_extract(project, text)
if data["mentions"]:
for user in data["mentions"]:
obj.add_watcher(user)
# Adding the person who edited the object to the watchers
if comment and not user.is_system:
obj.add_watcher(user)
return data.get("mentions")
def _filter_by_permissions(obj, user):
@ -296,10 +317,11 @@ def send_sync_notifications(notification_id):
msg_id = 'taiga-system'
now = datetime.datetime.now()
project_name = remove_lr_cr(notification.project.name)
format_args = {
"unsubscribe_url": resolve_front_url('settings-mail-notifications'),
"project_slug": notification.project.slug,
"project_name": notification.project.name,
"project_name": project_name,
"msg_id": msg_id,
"time": int(now.timestamp()),
"domain": domain

View File

@ -0,0 +1,141 @@
# -*- coding: utf-8 -*-
# Copyright (C) 2014-2018 Taiga Agile LLC
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.contrib.contenttypes.models import ContentType
from django.db import transaction
from django.utils import timezone
from taiga.events import events
from taiga.events import middleware as mw
from . import choices
from . import models
from . import serializers
def _filter_recipients(project, user, recipients):
notify_policies = models.NotifyPolicy.objects.filter(
user_id__in=recipients,
project=project,
web_notify_level=True).exclude(user_id=user.id).all()
return [notify_policy.user_id for notify_policy in notify_policies]
def _push_to_web_notifications(event_type, data, recipients,
serializer_class=None):
if not serializer_class:
serializer_class = serializers.ObjectNotificationSerializer
serializer = serializer_class(data)
for user_id in recipients:
with transaction.atomic():
models.WebNotification.objects.create(
event_type=event_type.value,
created=timezone.now(),
user_id=user_id,
data=serializer.data,
)
session_id = mw.get_current_session_id()
events.emit_event_for_user_notification(user_id,
session_id=session_id,
event_type=event_type.value,
data=serializer.data)
def on_assigned_to(sender, user, obj, **kwargs):
event_type = choices.WebNotificationType.assigned
data = {
"project": obj.project,
"user": user,
"obj": obj,
}
recipients = _filter_recipients(obj.project, user,
[obj.assigned_to.id])
_push_to_web_notifications(event_type, data, recipients)
def on_assigned_users(sender, user, obj, new_assigned_users, **kwargs):
event_type = choices.WebNotificationType.assigned
data = {
"project": obj.project,
"user": user,
"obj": obj,
}
recipients = _filter_recipients(obj.project, user,
[user.id for user in new_assigned_users])
_push_to_web_notifications(event_type, data, recipients)
def on_watchers_added(sender, user, obj, new_watchers, **kwargs):
event_type = choices.WebNotificationType.added_as_watcher
data = {
"project": obj.project,
"user": user,
"obj": obj,
}
recipients = _filter_recipients(obj.project, user, new_watchers)
_push_to_web_notifications(event_type, data, recipients)
def on_members_added(sender, user, project, new_members, **kwargs):
serializer_class = serializers.NotificationDataSerializer
event_type = choices.WebNotificationType.added_as_member
data = {
"project": project,
"user": user,
}
recipients = _filter_recipients(project, user,
[member.user_id for member in new_members
if member.user_id])
_push_to_web_notifications(event_type, data, recipients, serializer_class)
def on_mentions(sender, user, obj, mentions, **kwargs):
content_type = ContentType.objects.get_for_model(obj)
valid_content_types = ['issue', 'task', 'userstory']
if content_type.model in valid_content_types:
event_type = choices.WebNotificationType.mentioned
data = {
"project": obj.project,
"user": user,
"obj": obj,
}
recipients = _filter_recipients(obj.project, user,
[user.id for user in mentions])
_push_to_web_notifications(event_type, data, recipients)
def on_comment_mentions(sender, user, obj, mentions, **kwargs):
event_type = choices.WebNotificationType.mentioned_in_comment
data = {
"project": obj.project,
"user": user,
"obj": obj,
}
recipients = _filter_recipients(obj.project, user,
[user.id for user in mentions])
_push_to_web_notifications(event_type, data, recipients)
def on_comment(sender, user, obj, watchers, **kwargs):
event_type = choices.WebNotificationType.comment
data = {
"project": obj.project,
"user": user,
"obj": obj,
}
recipients = _filter_recipients(obj.project, user, watchers)
_push_to_web_notifications(event_type, data, recipients)

View File

@ -290,6 +290,8 @@ class ProjectSerializer(serializers.LightSerializer):
is_fan = Field(attr="is_fan_attr")
my_homepage = MethodField()
def get_members(self, obj):
assert hasattr(obj, "members_attr"), "instance must have a members_attr attribute"
if obj.members_attr is None:
@ -374,6 +376,13 @@ class ProjectSerializer(serializers.LightSerializer):
def get_logo_big_url(self, obj):
return services.get_logo_big_thumbnail_url(obj)
def get_my_homepage(self, obj):
assert hasattr(obj, "my_homepage_attr"), "instance must have a my_homepage_attr attribute"
if obj.my_homepage_attr is None:
return False
return obj.my_homepage_attr
class ProjectDetailSerializer(ProjectSerializer):
epic_statuses = Field(attr="epic_statuses_attr")

View File

View File

@ -0,0 +1,75 @@
# -*- coding: utf-8 -*-
# Copyright (C) 2014-2017 Andrey Antukh <niwi@niwi.nz>
# Copyright (C) 2014-2017 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014-2017 David Barragán <bameda@dbarragan.com>
# Copyright (C) 2014-2017 Alejandro Alonso <alejandro.alonso@kaleidos.net>
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.db.models import Q
from taiga.base import response
from taiga.base.api import ModelCrudViewSet, ReadOnlyListViewSet
from taiga.projects.settings.choices import HOMEPAGE_CHOICES
from taiga.projects.models import Project
from . import models
from . import permissions
from . import serializers
from . import services
from . import validators
class UserProjectSettingsViewSet(ModelCrudViewSet):
serializer_class = serializers.UserProjectSettingsSerializer
permission_classes = (permissions.UserProjectSettingsPermission,)
validator_class = validators.UserProjectSettingsValidator
def _build_user_project_settings(self):
projects = Project.objects.filter(
Q(owner=self.request.user) |
Q(memberships__user=self.request.user)
).distinct()
for project in projects:
services.create_user_project_settings_if_not_exists(
project, self.request.user)
def get_queryset(self):
if self.request.user.is_anonymous():
return models.UserProjectSettings.objects.none()
self._build_user_project_settings()
return models.UserProjectSettings.objects.filter(user=self.request.user)\
.filter(
Q(project__owner=self.request.user) |
Q(project__memberships__user=self.request.user)
).distinct()
def list(self, request, *args, **kwargs):
qs = self.get_queryset()
project_id = request.QUERY_PARAMS.get("project", None)
if project_id:
qs = qs.filter(project_id=project_id)
serializer = self.get_serializer(qs, many=True)
return response.Ok(serializer.data)
class SectionsViewSet(ReadOnlyListViewSet):
def list(self, request, *args, **kwargs):
return response.Response(HOMEPAGE_CHOICES)

View File

@ -0,0 +1,39 @@
# -*- coding: utf-8 -*-
# Copyright (C) 2014-2017 Andrey Antukh <niwi@niwi.nz>
# Copyright (C) 2014-2017 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014-2017 David Barragán <bameda@dbarragan.com>
# Copyright (C) 2014-2017 Alejandro Alonso <alejandro.alonso@kaleidos.net>
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import enum
from django.utils.translation import ugettext_lazy as _
class Section(enum.IntEnum):
timeline = 1
epics = 2
backlog = 3
kanban = 4
issues = 5
wiki = 6
HOMEPAGE_CHOICES = (
(Section.timeline, _("Timeline")),
(Section.epics, _("Epics")),
(Section.backlog, _("Backlog")),
(Section.kanban, _("Kanban")),
(Section.issues, _("Issues")),
(Section.wiki, _("TeamWiki")),
)

View File

@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.2 on 2018-09-24 11:49
from __future__ import unicode_literals
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import taiga.projects.settings.choices
class Migration(migrations.Migration):
initial = True
dependencies = [
('projects', '0061_auto_20180918_1355'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='UserProjectSettings',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('homepage', models.SmallIntegerField(choices=[(taiga.projects.settings.choices.Section(1), 'Timeline'), (taiga.projects.settings.choices.Section(2), 'Epics'), (taiga.projects.settings.choices.Section(3), 'Backlog'), (taiga.projects.settings.choices.Section(4), 'Kanban'), (taiga.projects.settings.choices.Section(5), 'Issues'), (taiga.projects.settings.choices.Section(6), 'TeamWiki')], default=taiga.projects.settings.choices.Section(1))),
('created_at', models.DateTimeField(default=django.utils.timezone.now)),
('modified_at', models.DateTimeField()),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_project_settings', to='projects.Project')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_project_settings', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['created_at'],
},
),
migrations.AlterUniqueTogether(
name='userprojectsettings',
unique_together=set([('project', 'user')]),
),
]

View File

@ -0,0 +1,49 @@
# -*- coding: utf-8 -*-
# Copyright (C) 2014-2017 Andrey Antukh <niwi@niwi.nz>
# Copyright (C) 2014-2017 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014-2017 David Barragán <bameda@dbarragan.com>
# Copyright (C) 2014-2017 Alejandro Alonso <alejandro.alonso@kaleidos.net>
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.conf import settings
from django.db import models
from django.utils import timezone
from .choices import HOMEPAGE_CHOICES, Section
class UserProjectSettings(models.Model):
"""
This class represents a persistence for
project user notifications preference.
"""
project = models.ForeignKey("projects.Project", related_name="user_project_settings")
user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name="user_project_settings")
homepage = models.SmallIntegerField(choices=HOMEPAGE_CHOICES,
default=Section.timeline)
created_at = models.DateTimeField(default=timezone.now)
modified_at = models.DateTimeField()
_importing = None
class Meta:
unique_together = ("project", "user",)
ordering = ["created_at"]
def save(self, *args, **kwargs):
if not self._importing or not self.modified_date:
self.modified_at = timezone.now()
return super().save(*args, **kwargs)

View File

@ -16,12 +16,13 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from .testing import *
from taiga.base.api.permissions import (TaigaResourcePermission, IsAuthenticated)
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql_psycopg2',
'NAME': 'taiga',
'USERNAME': 'postgres',
}
}
class UserProjectSettingsPermission(TaigaResourcePermission):
retrieve_perms = IsAuthenticated()
create_perms = IsAuthenticated()
update_perms = IsAuthenticated()
partial_update_perms = IsAuthenticated()
destroy_perms = IsAuthenticated()
list_perms = IsAuthenticated()

View File

@ -0,0 +1,38 @@
# -*- coding: utf-8 -*-
# Copyright (C) 2014-2017 Andrey Antukh <niwi@niwi.nz>
# Copyright (C) 2014-2017 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014-2017 David Barragán <bameda@dbarragan.com>
# Copyright (C) 2014-2017 Alejandro Alonso <alejandro.alonso@kaleidos.net>
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from taiga.base.api import serializers
from . import models
from taiga.projects.settings.utils import get_allowed_sections
class UserProjectSettingsSerializer(serializers.ModelSerializer):
project_name = serializers.SerializerMethodField("get_project_name")
allowed_sections = serializers.SerializerMethodField("get_allowed_sections")
class Meta:
model = models.UserProjectSettings
fields = ('id', 'project', 'project_name', 'homepage', 'allowed_sections')
def get_project_name(self, obj):
return obj.project.name
def get_allowed_sections(self, obj):
return get_allowed_sections(obj)

View File

@ -0,0 +1,67 @@
# -*- coding: utf-8 -*-
# Copyright (C) 2014-2017 Andrey Antukh <niwi@niwi.nz>
# Copyright (C) 2014-2017 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014-2017 David Barragán <bameda@dbarragan.com>
# Copyright (C) 2014-2017 Alejandro Alonso <alejandro.alonso@kaleidos.net>
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.apps import apps
from django.db import IntegrityError
from django.utils.translation import ugettext as _
from taiga.base import exceptions as exc
from taiga.projects.settings.choices import Section
def user_project_settings_exists(project, user) -> bool:
"""
Check if policy exists for specified project
and user.
"""
model_cls = apps.get_model("settings", "UserProjectSettings")
qs = model_cls.objects.filter(project=project,
user=user)
return qs.exists()
def create_user_project_settings(project, user, homepage=Section.timeline):
"""
Given a project and user, create notification policy for it.
"""
model_cls = apps.get_model("settings", "UserProjectSettings")
try:
return model_cls.objects.create(project=project,
user=user,
homepage=homepage)
except IntegrityError as e:
raise exc.IntegrityError(
_("Notify exists for specified user and project")) from e
def create_user_project_settings_if_not_exists(project, user,
homepage=Section.timeline):
"""
Given a project and user, create notification policy for it.
"""
model_cls = apps.get_model("settings", "UserProjectSettings")
try:
result = model_cls.objects.get_or_create(
project=project,
user=user,
defaults={"homepage": homepage}
)
return result[0]
except IntegrityError as e:
raise exc.IntegrityError(
_("Notify exists for specified user and project")) from e

View File

@ -0,0 +1,17 @@
from taiga.permissions.services import is_project_admin, user_has_perm
from taiga.projects.settings.choices import Section
def get_allowed_sections(obj):
sections = [Section.timeline]
active_modules = {'epics': 'view_epics', 'backlog': 'view_us',
'kanban': 'view_us', 'wiki': 'view_wiki_pages',
'issues': 'view_issues'}
for key in active_modules:
module_name = "is_{}_activated".format(key)
if getattr(obj.project, module_name) and \
user_has_perm(obj.user, active_modules[key], obj.project):
sections.append(getattr(Section, key))
return sections

View File

@ -0,0 +1,38 @@
# -*- coding: utf-8 -*-
# Copyright (C) 2014-2017 Andrey Antukh <niwi@niwi.nz>
# Copyright (C) 2014-2017 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014-2017 David Barragán <bameda@dbarragan.com>
# Copyright (C) 2014-2017 Alejandro Alonso <alejandro.alonso@kaleidos.net>
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.utils.translation import ugettext as _
from taiga.base.api import validators
from taiga.base.exceptions import ValidationError
from taiga.projects.settings.utils import get_allowed_sections
from . import models
class UserProjectSettingsValidator(validators.ModelValidator):
class Meta:
model = models.UserProjectSettings
read_only_fields = ('id', 'created_at', 'modified_at', 'project',
'user')
def validate_homepage(self, attrs, source):
if attrs[source] not in get_allowed_sections(self.object):
msg = _("You don't have access to this section")
raise ValidationError(msg)
return attrs

View File

@ -18,6 +18,7 @@
from django.apps import apps
from django.conf import settings
from django.db.models import F
from taiga.projects.notifications.services import create_notify_policy_if_not_exists
@ -32,15 +33,21 @@ def membership_post_delete(sender, instance, using, **kwargs):
instance.project.update_role_points()
## Notify policy
def membership_post_save(sender, instance, using, **kwargs):
if not instance.user:
return
create_notify_policy_if_not_exists(instance.project, instance.user)
def create_notify_policy(sender, instance, using, **kwargs):
if instance.user:
create_notify_policy_if_not_exists(instance.project, instance.user)
# Set project on top on user projects list
membership = apps.get_model("projects", "Membership")
membership.objects.filter(user=instance.user) \
.update(user_order=F('user_order') + 1)
membership.objects.filter(user=instance.user, project=instance.project)\
.update(user_order=0)
## Project attributes
def project_post_save(sender, instance, created, **kwargs):
"""
Populate new project dependen default data

View File

@ -30,7 +30,9 @@ from taiga.projects.history.mixins import HistoryResourceMixin
from taiga.projects.milestones.models import Milestone
from taiga.projects.mixins.by_ref import ByRefMixin
from taiga.projects.models import Project, TaskStatus
from taiga.projects.notifications.mixins import WatchedResourceMixin, WatchersViewSetMixin
from taiga.projects.notifications.mixins import AssignedToSignalMixin
from taiga.projects.notifications.mixins import WatchedResourceMixin
from taiga.projects.notifications.mixins import WatchersViewSetMixin
from taiga.projects.occ import OCCResourceMixin
from taiga.projects.tagging.api import TaggedResourceMixin
from taiga.projects.userstories.models import UserStory
@ -45,8 +47,10 @@ from . import validators
from . import utils as tasks_utils
class TaskViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, WatchedResourceMixin,
ByRefMixin, TaggedResourceMixin, BlockedByProjectMixin, ModelCrudViewSet):
class TaskViewSet(AssignedToSignalMixin, OCCResourceMixin, VotedResourceMixin,
HistoryResourceMixin, WatchedResourceMixin, ByRefMixin,
TaggedResourceMixin, BlockedByProjectMixin,
ModelCrudViewSet):
validator_class = validators.TaskValidator
queryset = models.Task.objects.all()
permission_classes = (permissions.TaskPermission,)
@ -271,6 +275,23 @@ class TaskViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, Wa
return response.Ok(tasks_serialized.data)
@list_route(methods=["POST"])
def bulk_update_milestone(self, request, **kwargs):
validator = validators.UpdateMilestoneBulkValidator(data=request.DATA)
if not validator.is_valid():
return response.BadRequest(validator.errors)
data = validator.data
project = get_object_or_404(Project, pk=data["project_id"])
milestone = get_object_or_404(Milestone, pk=data["milestone_id"])
self.check_permissions(request, "bulk_update_milestone", project)
ret = services.update_tasks_milestone_in_bulk(data["bulk_tasks"], milestone)
services.snapshot_tasks_in_bulk(data["bulk_tasks"], request.user)
return response.Ok(ret)
def _bulk_update_order(self, order_field, request, **kwargs):
validator = validators.UpdateTasksOrderBulkValidator(data=request.DATA)
if not validator.is_valid():

View File

@ -91,3 +91,7 @@ class Task(OCCModelMixin, WatchedModelMixin, BlockedMixin, TaggedMixin, DueDateM
def __str__(self):
return "({1}) {0}".format(self.ref, self.subject)
@property
def is_closed(self):
return self.status is not None and self.status.is_closed

View File

@ -35,6 +35,7 @@ class TaskPermission(TaigaResourcePermission):
csv_perms = AllowAny()
bulk_create_perms = HasProjectPerm('add_task')
bulk_update_order_perms = HasProjectPerm('modify_task')
bulk_update_milestone_perms = HasProjectPerm('modify_task')
upvote_perms = IsAuthenticated() & HasProjectPerm('view_tasks')
downvote_perms = IsAuthenticated() & HasProjectPerm('view_tasks')
watch_perms = IsAuthenticated() & HasProjectPerm('view_tasks')

View File

@ -112,6 +112,39 @@ def snapshot_tasks_in_bulk(bulk_data, user):
pass
def update_tasks_milestone_in_bulk(bulk_data: list, milestone: object):
"""
Update the milestone and the milestone order of some tasks adding
the extra orders needed to keep consistency.
`bulk_data` should be a list of dicts with the following format:
[{'task_id': <value>, 'order': <value>}, ...]
"""
tasks = milestone.tasks.all()
task_orders = {task.id: getattr(task, "taskboard_order") for task in tasks}
new_task_orders = {}
for e in bulk_data:
new_task_orders[e["task_id"]] = e["order"]
# The base orders where we apply the new orders must containg all
# the values
task_orders[e["task_id"]] = e["order"]
apply_order_updates(task_orders, new_task_orders)
task_milestones = {e["task_id"]: milestone.id for e in bulk_data}
task_ids = task_milestones.keys()
events.emit_event_for_ids(ids=task_ids,
content_type="tasks.task",
projectid=milestone.project.pk)
db.update_attr_in_bulk_for_ids(task_milestones, "milestone_id",
model=models.Task)
db.update_attr_in_bulk_for_ids(task_orders, "taskboard_order", models.Task)
return task_milestones
#####################################################
# CSV
#####################################################

View File

@ -31,9 +31,9 @@ def cached_prev_task(sender, instance, **kwargs):
instance.prev = sender.objects.get(id=instance.id)
####################################
# Signals for close US and Milestone
####################################
######################################
# Signals for close Task and Milestone
######################################
def try_to_close_or_open_us_and_milestone_when_create_or_edit_task(sender, instance, created, **kwargs):
_try_to_close_or_open_us_when_create_or_edit_task(instance)

View File

@ -153,3 +153,36 @@ class UpdateTasksOrderBulkValidator(ProjectExistsValidator, validators.Validator
"if it exists, to the same status, user story and/or milestone."))
return attrs
# Milestone bulk validators
class _TaskMilestoneBulkValidator(validators.Validator):
task_id = serializers.IntegerField()
order = serializers.IntegerField()
class UpdateMilestoneBulkValidator(ProjectExistsValidator, validators.Validator):
project_id = serializers.IntegerField()
milestone_id = serializers.IntegerField()
bulk_tasks = _TaskMilestoneBulkValidator(many=True)
def validate_milestone_id(self, attrs, source):
filters = {
"project__id": attrs["project_id"],
"id": attrs[source]
}
if not Milestone.objects.filter(**filters).exists():
raise ValidationError(_("The milestone isn't valid for the project"))
return attrs
def validate_bulk_tasks(self, attrs, source):
filters = {
"project__id": attrs["project_id"],
"id__in": [task["task_id"] for task in attrs[source]]
}
if models.Task.objects.filter(**filters).count() != len(filters["id__in"]):
raise ValidationError(_("All the tasks must be from the same project"))
return attrs

View File

@ -39,6 +39,7 @@ from taiga.projects.history.services import take_snapshot
from taiga.projects.milestones.models import Milestone
from taiga.projects.mixins.by_ref import ByRefMixin
from taiga.projects.models import Project, UserStoryStatus
from taiga.projects.notifications.mixins import AssignedUsersSignalMixin
from taiga.projects.notifications.mixins import WatchedResourceMixin
from taiga.projects.notifications.mixins import WatchersViewSetMixin
from taiga.projects.occ import OCCResourceMixin
@ -55,8 +56,10 @@ from . import services
from . import validators
class UserStoryViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, WatchedResourceMixin,
ByRefMixin, TaggedResourceMixin, BlockedByProjectMixin, ModelCrudViewSet):
class UserStoryViewSet(AssignedUsersSignalMixin, OCCResourceMixin,
VotedResourceMixin, HistoryResourceMixin,
WatchedResourceMixin, ByRefMixin, TaggedResourceMixin,
BlockedByProjectMixin, ModelCrudViewSet):
validator_class = validators.UserStoryValidator
queryset = models.UserStory.objects.all()
permission_classes = (permissions.UserStoryPermission,)

View File

@ -72,7 +72,6 @@ def update_milestone_of_tasks_when_edit_us(sender, instance, created, **kwargs):
def try_to_close_or_open_us_and_milestone_when_create_or_edit_us(sender, instance, created, **kwargs):
if instance._importing:
return
_try_to_close_or_open_us_when_create_or_edit_us(instance)
_try_to_close_or_open_milestone_when_create_or_edit_us(instance)

View File

@ -593,6 +593,30 @@ def attach_public_projects_same_owner(queryset, user, as_field="public_projects_
return queryset
def attach_my_homepage(queryset, user, as_field="my_homepage_attr"):
"""Attach a homepage array to each object of the queryset.
:param queryset: A Django projects queryset object.
:param as_field: Attach the settings homepage as an attribute with this name.
:return: Queryset object with the additional `as_field` field.
"""
model = queryset.model
if user is None or user.is_anonymous():
sql = "SELECT '{}'"
else:
sql = """
SELECT homepage
FROM settings_userprojectsettings
WHERE settings_userprojectsettings.project_id = {tbl}.id AND
settings_userprojectsettings.user_id = {user_id}"""
sql = sql.format(tbl=model._meta.db_table, user_id=user.id)
queryset = queryset.extra(select={as_field: sql})
return queryset
def attach_extra_info(queryset, user=None):
queryset = attach_members(queryset)
queryset = attach_closed_milestones(queryset)
@ -618,5 +642,6 @@ def attach_extra_info(queryset, user=None):
queryset = attach_private_projects_same_owner(queryset, user)
queryset = attach_public_projects_same_owner(queryset, user)
queryset = attach_milestones(queryset)
queryset = attach_my_homepage(queryset, user)
return queryset

View File

@ -43,10 +43,20 @@ from taiga.userstorage.api import StorageEntriesViewSet
router.register(r"user-storage", StorageEntriesViewSet, base_name="user-storage")
# Notify policies
# Notifications & Notify policies
from taiga.projects.notifications.api import NotifyPolicyViewSet
from taiga.projects.notifications.api import WebNotificationsViewSet
router.register(r"notify-policies", NotifyPolicyViewSet, base_name="notifications")
router.register(r"web-notifications", WebNotificationsViewSet, base_name="web-notifications")
router.register(r"web-notifications/set-as-read", WebNotificationsViewSet, base_name="web-notifications")
router.register(r"web-notifications/(?P<resource_id>\d+)/set-as-read", WebNotificationsViewSet, base_name="web-notifications")
# Project settings
from taiga.projects.settings.api import UserProjectSettingsViewSet, SectionsViewSet
router.register(r"user-project-settings", UserProjectSettingsViewSet, base_name="user-project-settings")
router.register(r"sections", SectionsViewSet, base_name="sections")
# Projects & Selectors

View File

@ -80,7 +80,9 @@ class TimelineViewSet(ReadOnlyListViewSet):
qs = qs.extra(where=[
"""
NOT(
data::text LIKE '%%\"values_diff\": {}%%'
(data::text LIKE '%%\"values_diff\": {}%%'
OR
data->'values_diff'->'attachments'->'new' = '[]')
AND
event_type::text = ANY('{issues.issue.change,
tasks.task.change,

View File

@ -626,6 +626,10 @@ def create_issue(**kwargs):
return IssueFactory.create(**defaults)
class Missing:
pass
def create_task(**kwargs):
"Create a task and along with its dependencies."
owner = kwargs.pop("owner", None)
@ -636,13 +640,23 @@ def create_task(**kwargs):
if project is None:
project = ProjectFactory.create(owner=owner)
status = kwargs.pop("status", None)
milestone = kwargs.pop("milestone", None)
defaults = {
"project": project,
"owner": owner,
"status": TaskStatusFactory.create(project=project),
"milestone": MilestoneFactory.create(project=project),
"user_story": UserStoryFactory.create(project=project, owner=owner),
"status": status or TaskStatusFactory.create(project=project),
"milestone": milestone or MilestoneFactory.create(project=project),
}
user_story = kwargs.pop("user_story", Missing)
defaults["user_story"] = (
UserStoryFactory.create(project=project, owner=owner, milestone=defaults["milestone"])
if user_story is Missing
else user_story
)
defaults.update(kwargs)
return TaskFactory.create(**defaults)

View File

@ -79,6 +79,9 @@ def data():
default_points=m.private_points1)
m.private_project1 = attach_project_extra_info(Project.objects.all()).get(id=m.private_project1.id)
m.private_sprint1 = f.MilestoneFactory.create(project=m.private_project1,
owner=m.project_owner)
m.private_points2 = f.PointsFactory()
m.private_project2 = f.ProjectFactory(is_private=True,
anon_permissions=[],
@ -697,6 +700,28 @@ def test_user_story_action_bulk_update_order(client, data):
assert results == [401, 403, 403, 451, 451]
def test_user_story_action_bulk_update_milestone(client, data):
url = reverse('userstories-bulk-update-milestone')
users = [
None,
data.registered_user,
data.project_member_without_perms,
data.project_member_with_perms,
data.project_owner
]
post_data = json.dumps({
"bulk_stories": [
{"us_id": data.private_user_story1.id, "order": 2}
],
"milestone_id": data.private_sprint1.pk,
"project_id": data.private_project1.pk
})
results = helper_test_http_method(client, 'post', url, post_data, users)
assert results == [401, 403, 403, 204, 204]
def test_user_story_action_upvote(client, data):
public_url = reverse('userstories-upvote', kwargs={"pk": data.public_user_story.pk})
private_url1 = reverse('userstories-upvote', kwargs={"pk": data.private_user_story1.pk})

View File

@ -39,6 +39,78 @@ import pytest
pytestmark = pytest.mark.django_db
def create_filter_issues_context():
data = {}
data["project"] = f.ProjectFactory.create()
project = data["project"]
data["users"] = [f.UserFactory.create(is_superuser=True) for i in range(0, 3)]
data["roles"] = [f.RoleFactory.create() for i in range(0, 3)]
user_roles = zip(data["users"], data["roles"])
# Add membership fixtures
[f.MembershipFactory.create(user=user, project=project, role=role) for (user, role) in user_roles]
data["statuses"] = [f.IssueStatusFactory.create(project=project) for i in range(0, 4)]
data["types"] = [f.IssueTypeFactory.create(project=project) for i in range(0, 2)]
data["severities"] = [f.SeverityFactory.create(project=project) for i in range(0, 4)]
data["priorities"] = [f.PriorityFactory.create(project=project) for i in range(0, 4)]
data["tags"] = ["test1test2test3", "test1", "test2", "test3"]
# ------------------------------------------------------------------------------------------------
# | Issue | Owner | Assigned To | Status | Type | Priority | Severity | Tags |
# |-------#--------#-------------#---------#-------#-----------#-----------#---------------------|
# | 0 | user2 | None | status3 | type1 | priority2 | severity1 | tag1 |
# | 1 | user1 | None | status3 | type2 | priority2 | severity1 | tag2 |
# | 2 | user3 | None | status1 | type1 | priority3 | severity2 | tag1 tag2 |
# | 3 | user2 | None | status0 | type2 | priority3 | severity1 | tag3 |
# | 4 | user1 | user1 | status0 | type1 | priority2 | severity3 | tag1 tag2 tag3 |
# | 5 | user3 | user1 | status2 | type2 | priority3 | severity2 | tag3 |
# | 6 | user2 | user1 | status3 | type1 | priority2 | severity0 | tag1 tag2 |
# | 7 | user1 | user2 | status0 | type2 | priority1 | severity3 | tag3 |
# | 8 | user3 | user2 | status3 | type1 | priority0 | severity1 | tag1 |
# | 9 | user2 | user3 | status1 | type2 | priority0 | severity2 | tag0 |
# ------------------------------------------------------------------------------------------------
(user1, user2, user3, ) = data["users"]
(status0, status1, status2, status3 ) = data["statuses"]
(type1, type2, ) = data["types"]
(severity0, severity1, severity2, severity3, ) = data["severities"]
(priority0, priority1, priority2, priority3, ) = data["priorities"]
(tag0, tag1, tag2, tag3, ) = data["tags"]
f.IssueFactory.create(project=project, owner=user2, assigned_to=None,
status=status3, type=type1, priority=priority2, severity=severity1,
tags=[tag1])
f.IssueFactory.create(project=project, owner=user1, assigned_to=None,
status=status3, type=type2, priority=priority2, severity=severity1,
tags=[tag2])
f.IssueFactory.create(project=project, owner=user3, assigned_to=None,
status=status1, type=type1, priority=priority3, severity=severity2,
tags=[tag1, tag2])
f.IssueFactory.create(project=project, owner=user2, assigned_to=None,
status=status0, type=type2, priority=priority3, severity=severity1,
tags=[tag3])
f.IssueFactory.create(project=project, owner=user1, assigned_to=user1,
status=status0, type=type1, priority=priority2, severity=severity3,
tags=[tag1, tag2, tag3])
f.IssueFactory.create(project=project, owner=user3, assigned_to=user1,
status=status2, type=type2, priority=priority3, severity=severity2,
tags=[tag3])
f.IssueFactory.create(project=project, owner=user2, assigned_to=user1,
status=status3, type=type1, priority=priority2, severity=severity0,
tags=[tag1, tag2])
f.IssueFactory.create(project=project, owner=user1, assigned_to=user2,
status=status0, type=type2, priority=priority1, severity=severity3,
tags=[tag3])
f.IssueFactory.create(project=project, owner=user3, assigned_to=user2,
status=status3, type=type1, priority=priority0, severity=severity1,
tags=[tag1])
f.IssueFactory.create(project=project, owner=user2, assigned_to=user3,
status=status1, type=type2, priority=priority0, severity=severity2,
tags=[tag0])
return data
def test_get_issues_from_bulk():
data = """
Issue #1
@ -370,86 +442,63 @@ def test_api_filter_by_finished_date(client):
assert response.data[0]["ref"] == finished_issue.ref
@pytest.mark.parametrize("filter_name,collection,expected,exclude_expected,is_text", [
('type', 'types', 5, 5, False),
('severity', 'severities', 1, 9, False),
('priority', 'priorities', 2, 8, False),
('status', 'statuses', 3, 7, False),
('assigned_to', 'users', 3, 7, False),
('tags', 'tags', 1, 9, True),
('owner', 'users', 3, 7, False),
('role', 'roles', 3, 7, False),
])
def test_api_filters(client, filter_name, collection, expected, exclude_expected, is_text):
data = create_filter_issues_context()
project = data["project"]
options = data[collection]
client.login(data["users"][0])
if is_text:
param = options[0]
else:
param = options[0].id
# include test
url = "{}?project={}&{}={}".format(reverse('issues-list'), project.id, filter_name, param)
response = client.get(url)
assert response.status_code == 200
assert len(response.data) == expected
# exclude test
url = "{}?project={}&exclude_{}={}".format(reverse('issues-list'), project.id, filter_name, param)
response = client.get(url)
assert response.status_code == 200
assert len(response.data) == exclude_expected
def test_mulitple_exclude_filter_tags(client):
data = create_filter_issues_context()
project = data["project"]
client.login(data["users"][0])
tags = data["tags"]
url = "{}?project={}&exclude_tags={},{}".format(reverse('issues-list'), project.id, tags[1], tags[2])
response = client.get(url)
assert response.status_code == 200
assert len(response.data) == 4
def test_api_filters_data(client):
project = f.ProjectFactory.create()
user1 = f.UserFactory.create(is_superuser=True)
f.MembershipFactory.create(user=user1, project=project)
user2 = f.UserFactory.create(is_superuser=True)
f.MembershipFactory.create(user=user2, project=project)
user3 = f.UserFactory.create(is_superuser=True)
f.MembershipFactory.create(user=user3, project=project)
status0 = f.IssueStatusFactory.create(project=project)
status1 = f.IssueStatusFactory.create(project=project)
status2 = f.IssueStatusFactory.create(project=project)
status3 = f.IssueStatusFactory.create(project=project)
type1 = f.IssueTypeFactory.create(project=project)
type2 = f.IssueTypeFactory.create(project=project)
severity0 = f.SeverityFactory.create(project=project)
severity1 = f.SeverityFactory.create(project=project)
severity2 = f.SeverityFactory.create(project=project)
severity3 = f.SeverityFactory.create(project=project)
priority0 = f.PriorityFactory.create(project=project)
priority1 = f.PriorityFactory.create(project=project)
priority2 = f.PriorityFactory.create(project=project)
priority3 = f.PriorityFactory.create(project=project)
tag0 = "test1test2test3"
tag1 = "test1"
tag2 = "test2"
tag3 = "test3"
# ------------------------------------------------------------------------------------------------
# | Issue | Owner | Assigned To | Status | Type | Priority | Severity | Tags |
# |-------#--------#-------------#---------#-------#-----------#-----------#---------------------|
# | 0 | user2 | None | status3 | type1 | priority2 | severity1 | tag1 |
# | 1 | user1 | None | status3 | type2 | priority2 | severity1 | tag2 |
# | 2 | user3 | None | status1 | type1 | priority3 | severity2 | tag1 tag2 |
# | 3 | user2 | None | status0 | type2 | priority3 | severity1 | tag3 |
# | 4 | user1 | user1 | status0 | type1 | priority2 | severity3 | tag1 tag2 tag3 |
# | 5 | user3 | user1 | status2 | type2 | priority3 | severity2 | tag3 |
# | 6 | user2 | user1 | status3 | type1 | priority2 | severity0 | tag1 tag2 |
# | 7 | user1 | user2 | status0 | type2 | priority1 | severity3 | tag3 |
# | 8 | user3 | user2 | status3 | type1 | priority0 | severity1 | tag1 |
# | 9 | user2 | user3 | status1 | type2 | priority0 | severity2 | tag0 |
# ------------------------------------------------------------------------------------------------
issue0 = f.IssueFactory.create(project=project, owner=user2, assigned_to=None,
status=status3, type=type1, priority=priority2, severity=severity1,
tags=[tag1])
issue1 = f.IssueFactory.create(project=project, owner=user1, assigned_to=None,
status=status3, type=type2, priority=priority2, severity=severity1,
tags=[tag2])
issue2 = f.IssueFactory.create(project=project, owner=user3, assigned_to=None,
status=status1, type=type1, priority=priority3, severity=severity2,
tags=[tag1, tag2])
issue3 = f.IssueFactory.create(project=project, owner=user2, assigned_to=None,
status=status0, type=type2, priority=priority3, severity=severity1,
tags=[tag3])
issue4 = f.IssueFactory.create(project=project, owner=user1, assigned_to=user1,
status=status0, type=type1, priority=priority2, severity=severity3,
tags=[tag1, tag2, tag3])
issue5 = f.IssueFactory.create(project=project, owner=user3, assigned_to=user1,
status=status2, type=type2, priority=priority3, severity=severity2,
tags=[tag3])
issue6 = f.IssueFactory.create(project=project, owner=user2, assigned_to=user1,
status=status3, type=type1, priority=priority2, severity=severity0,
tags=[tag1, tag2])
issue7 = f.IssueFactory.create(project=project, owner=user1, assigned_to=user2,
status=status0, type=type2, priority=priority1, severity=severity3,
tags=[tag3])
issue8 = f.IssueFactory.create(project=project, owner=user3, assigned_to=user2,
status=status3, type=type1, priority=priority0, severity=severity1,
tags=[tag1])
issue9 = f.IssueFactory.create(project=project, owner=user2, assigned_to=user3,
status=status1, type=type2, priority=priority0, severity=severity2,
tags=[tag0])
data = create_filter_issues_context()
project = data["project"]
(user1, user2, user3, ) = data["users"]
(status0, status1, status2, status3, ) = data["statuses"]
(type1, type2, ) = data["types"]
(priority0, priority1, priority2, priority3, ) = data["priorities"]
(severity0, severity1, severity2, severity3, ) = data["severities"]
(tag0, tag1, tag2, tag3, ) = data["tags"]
url = reverse("issues-filters-data") + "?project={}".format(project.id)
client.login(user1)
## No filter
@ -758,6 +807,69 @@ def test_api_create_in_bulk_with_status_milestone(client):
assert response.data[0]["milestone"] == milestone.id
def test_api_update_milestone_in_bulk(client):
project = f.create_project()
f.MembershipFactory.create(project=project, user=project.owner, is_admin=True)
milestone1 = f.MilestoneFactory(project=project)
milestone2 = f.MilestoneFactory(project=project)
i1 = f.create_issue(project=project, milestone=milestone1)
i2 = f.create_issue(project=project, milestone=milestone1)
i3 = f.create_issue(project=project, milestone=milestone1)
assert project.milestones.get(id=milestone1.id).issues.count() == 3
url = reverse("issues-bulk-update-milestone")
data = {
"project_id": project.id,
"milestone_id": milestone2.id,
"bulk_issues": [
{"issue_id": i1.id},
{"issue_id": i2.id},
{"issue_id": i3.id}
]
}
client.login(project.owner)
response = client.json.post(url, json.dumps(data))
assert response.status_code == 200, response.data
assert response.data[i1.id] == milestone2.id
assert response.data[i2.id] == milestone2.id
assert response.data[i3.id] == milestone2.id
def test_api_update_milestone_in_bulk_invalid_milestone(client):
project = f.create_project()
f.MembershipFactory.create(project=project, user=project.owner, is_admin=True)
milestone1 = f.MilestoneFactory(project=project)
milestone2 = f.MilestoneFactory()
i1 = f.create_issue(project=project, milestone=milestone1)
i2 = f.create_issue(project=project, milestone=milestone1)
i3 = f.create_issue(project=project, milestone=milestone1)
url = reverse("issues-bulk-update-milestone")
data = {
"project_id": project.id,
"milestone_id": milestone2.id,
"bulk_issues": [
{"issue_id": i1.id},
{"issue_id": i2.id},
{"issue_id": i3.id}
]
}
client.login(project.owner)
response = client.json.post(url, json.dumps(data))
assert response.status_code == 400
assert "milestone_id" in response.data
def test_get_issues(client):
user = f.UserFactory.create()
project = f.ProjectFactory.create(owner=user)

View File

@ -26,7 +26,6 @@ from urllib.parse import quote
from django.core.urlresolvers import reverse
from taiga.base.utils import json
from taiga.projects.userstories.serializers import UserStorySerializer
from .. import factories as f
@ -180,3 +179,211 @@ def test_api_filter_by_milestone__estimated_start_and_end(client, field_name):
assert number_of_milestones == expection, param
if number_of_milestones > 0:
assert response.data[0]["slug"] == milestone.slug
def test_api_update_milestone_in_bulk_userstories(client):
project = f.create_project()
f.MembershipFactory.create(project=project, user=project.owner,
is_admin=True)
milestone1 = f.MilestoneFactory.create(project=project)
milestone2 = f.MilestoneFactory.create(project=project)
us1 = f.create_userstory(project=project, milestone=milestone1,
sprint_order=1)
us2 = f.create_userstory(project=project, milestone=milestone1,
sprint_order=2)
assert project.milestones.get(id=milestone1.id).user_stories.count() == 2
url = reverse("milestones-move-userstories-to-sprint", kwargs={"pk": milestone1.pk})
data = {
"project_id": project.id,
"milestone_id": milestone2.id,
"bulk_stories": [{"us_id": us2.id, "order": 2}]
}
client.login(project.owner)
response = client.json.post(url, json.dumps(data))
assert response.status_code == 204, response.data
assert project.milestones.get(id=milestone1.id).user_stories.count() == 1
assert project.milestones.get(id=milestone2.id).user_stories.count() == 1
def test_api_move_userstories_to_another_sprint(client):
project = f.create_project()
f.MembershipFactory.create(project=project, user=project.owner,
is_admin=True)
milestone1 = f.MilestoneFactory.create(project=project)
milestone2 = f.MilestoneFactory.create(project=project)
us1 = f.create_userstory(project=project, milestone=milestone1,
sprint_order=1)
us2 = f.create_userstory(project=project, milestone=milestone1,
sprint_order=2)
assert project.milestones.get(id=milestone1.id).user_stories.count() == 2
url = reverse("milestones-move-userstories-to-sprint", kwargs={"pk": milestone1.pk})
data = {
"project_id": project.id,
"milestone_id": milestone2.id,
"bulk_stories": [{"us_id": us2.id, "order": 2}]
}
client.login(project.owner)
response = client.json.post(url, json.dumps(data))
assert response.status_code == 204, response.data
assert project.milestones.get(id=milestone1.id).user_stories.count() == 1
assert project.milestones.get(id=milestone2.id).user_stories.count() == 1
def test_api_move_userstories_to_another_sprint_close_previous(client):
project = f.create_project()
f.MembershipFactory.create(project=project, user=project.owner,
is_admin=True)
milestone1 = f.MilestoneFactory.create(project=project)
milestone2 = f.MilestoneFactory.create(project=project)
closed_status = f.UserStoryStatusFactory.create(is_closed=True)
us1 = f.create_userstory(project=project, milestone=milestone1,
sprint_order=1, status=closed_status)
us2 = f.create_userstory(project=project, milestone=milestone1, sprint_order=2)
assert milestone1.user_stories.count() == 2
assert not milestone1.closed
url = reverse("milestones-move-userstories-to-sprint", kwargs={"pk": milestone1.pk})
data = {
"project_id": project.id,
"milestone_id": milestone2.id,
"bulk_stories": [{"us_id": us2.id, "order": 2}]
}
client.login(project.owner)
response = client.json.post(url, json.dumps(data))
assert response.status_code == 204, response.data
assert project.milestones.get(id=milestone1.id).user_stories.count() == 1
assert project.milestones.get(id=milestone2.id).user_stories.count() == 1
assert project.milestones.get(id=milestone1.id).closed
def test_api_move_tasks_to_another_sprint(client):
project = f.create_project()
f.MembershipFactory.create(project=project, user=project.owner,
is_admin=True)
milestone1 = f.MilestoneFactory.create(project=project)
milestone2 = f.MilestoneFactory.create(project=project)
task1 = f.create_task(project=project, milestone=milestone1, taskboard_order=1)
task2 = f.create_task(project=project, milestone=milestone1, taskboard_order=2)
assert project.milestones.get(id=milestone1.id).tasks.count() == 2
url = reverse("milestones-move-tasks-to-sprint", kwargs={"pk": milestone1.pk})
data = {
"project_id": project.id,
"milestone_id": milestone2.id,
"bulk_tasks": [{"task_id": task2.id, "order": 2}]
}
client.login(project.owner)
response = client.json.post(url, json.dumps(data))
assert response.status_code == 204, response.data
assert project.milestones.get(id=milestone1.id).tasks.count() == 1
assert project.milestones.get(id=milestone2.id).tasks.count() == 1
def test_api_move_tasks_to_another_sprint_close_previous(client):
project = f.create_project()
f.MembershipFactory.create(project=project, user=project.owner,
is_admin=True)
milestone1 = f.MilestoneFactory.create(project=project)
milestone2 = f.MilestoneFactory.create(project=project)
closed_status = f.TaskStatusFactory.create(project=project, is_closed=True)
task1 = f.create_task(project=project, milestone=milestone1, taskboard_order=1,
status=closed_status, user_story=None)
task2 = f.create_task(project=project, milestone=milestone1, taskboard_order=2,
user_story=None)
assert project.milestones.get(id=milestone1.id).tasks.count() == 2
assert not milestone1.closed
url = reverse("milestones-move-tasks-to-sprint", kwargs={"pk": milestone1.pk})
data = {
"project_id": project.id,
"milestone_id": milestone2.id,
"bulk_tasks": [{"task_id": task2.id, "order": 2}]
}
client.login(project.owner)
response = client.json.post(url, json.dumps(data))
assert response.status_code == 204, response.data
assert project.milestones.get(id=milestone1.id).tasks.count() == 1
assert project.milestones.get(id=milestone2.id).tasks.count() == 1
assert project.milestones.get(id=milestone1.id).closed
def test_api_move_issues_to_another_sprint(client):
project = f.create_project()
f.MembershipFactory.create(project=project, user=project.owner,
is_admin=True)
milestone1 = f.MilestoneFactory.create(project=project)
milestone2 = f.MilestoneFactory.create(project=project)
issue1 = f.create_issue(project=project, milestone=milestone1)
issue2 = f.create_issue(project=project, milestone=milestone1)
assert project.milestones.get(id=milestone1.id).issues.count() == 2
url = reverse("milestones-move-issues-to-sprint", kwargs={"pk": milestone1.pk})
data = {
"project_id": project.id,
"milestone_id": milestone2.id,
"bulk_issues": [{"issue_id": issue2.id, "order": 2}]
}
client.login(project.owner)
response = client.json.post(url, json.dumps(data))
assert response.status_code == 204, response.data
assert project.milestones.get(id=milestone1.id).issues.count() == 1
assert project.milestones.get(id=milestone2.id).issues.count() == 1
def test_api_move_issues_to_another_sprint_close_previous(client):
project = f.create_project()
f.MembershipFactory.create(project=project, user=project.owner,
is_admin=True)
milestone1 = f.MilestoneFactory.create(project=project)
milestone2 = f.MilestoneFactory.create(project=project)
closed_status = f.IssueStatusFactory.create(project=project,
is_closed=True)
issue1 = f.create_issue(project=project, milestone=milestone1,
status=closed_status)
issue2 = f.create_issue(project=project, milestone=milestone1)
assert project.milestones.get(id=milestone1.id).closed is False
assert project.milestones.get(id=milestone1.id).issues.count() == 2
url = reverse("milestones-move-issues-to-sprint", kwargs={"pk": milestone1.pk})
data = {
"project_id": project.id,
"milestone_id": milestone2.id,
"bulk_issues": [{"issue_id": issue2.id, "order": 2}]
}
client.login(project.owner)
response = client.json.post(url, json.dumps(data))
assert response.status_code == 204, response.data
assert project.milestones.get(id=milestone1.id).issues.count() == 1
assert project.milestones.get(id=milestone2.id).issues.count() == 1
assert project.milestones.get(id=milestone1.id).closed

View File

@ -19,7 +19,6 @@
import pytest
import time
import math
import base64
import datetime
import hashlib
@ -35,17 +34,16 @@ from django.utils import timezone
from django.apps import apps
from .. import factories as f
from taiga.base.api.settings import api_settings
from taiga.base.utils import json
from taiga.projects.notifications import services
from taiga.projects.notifications import utils
from taiga.projects.notifications import models
from taiga.projects.notifications.choices import NotifyLevel
from taiga.projects.notifications.choices import WebNotificationType
from taiga.projects.history.choices import HistoryType
from taiga.projects.history.services import take_snapshot
from taiga.projects.issues.serializers import IssueSerializer
from taiga.projects.userstories.serializers import UserStorySerializer
from taiga.projects.tasks.serializers import TaskSerializer
from taiga.permissions.choices import MEMBERS_PERMISSIONS
from taiga.users.gravatar import get_user_gravatar_id
pytestmark = pytest.mark.django_db
@ -57,6 +55,20 @@ def mail():
return mail
@pytest.mark.parametrize(
"header, expected",
[
("", ""),
("One line", "One line"),
("Two \nlines", "Two lines"),
("Mix \r\nCR and LF \rin the string", "Mix CR and LF in the string"),
]
)
def test_remove_lr_cr(header, expected):
rv = services.remove_lr_cr(header)
assert rv == expected
def test_create_retrieve_notify_policy():
project = f.ProjectFactory.create()
@ -1074,3 +1086,340 @@ def parse_ms_thread_index(index):
ts.append(ts[-1] + datetime.timedelta(microseconds=(f << 18)//10))
return guid, ts
def _notification_data(project, user, obj, content_type):
return {
"project": {
"id": project.pk,
"slug": project.slug,
"name": project.name,
},
"obj": {
"id": obj.pk,
"ref": obj.ref,
"subject": obj.subject,
"content_type": content_type,
},
"user": {
'big_photo': None,
'date_joined': user.date_joined.strftime(
api_settings.DATETIME_FORMAT),
'gravatar_id': get_user_gravatar_id(user),
'id': user.pk,
'is_profile_visible': True,
'name': user.get_full_name(),
'photo': None,
'username': user.username
},
}
def test_issue_updated_generates_web_notifications(client):
project = f.ProjectFactory.create()
role = f.RoleFactory.create(
project=project,
permissions=['view_issues', 'modify_issue']
)
member1 = f.MembershipFactory.create(project=project, role=role)
member2 = f.MembershipFactory.create(project=project, role=role)
member3 = f.MembershipFactory.create(project=project, role=role)
member4 = f.MembershipFactory.create(project=project, role=role)
issue = f.IssueFactory.create(project=project, owner=member1.user)
client.login(member1.user)
mock_path = "taiga.projects.issues.api.IssueViewSet.pre_conditions_on_save"
with patch(mock_path):
client.patch(
reverse("issues-detail", args=[issue.pk]),
json.dumps({
"description": "Lorem ipsum @%s dolor sit amet" %
member4.user.username,
"assigned_to": member2.user.pk,
"watchers": [member3.user.pk],
"version": issue.version
}),
content_type="application/json"
)
assert 3 == models.WebNotification.objects.count()
notifications = models.WebNotification.objects.all()
notification_data = _notification_data(project, member1.user, issue,
'issue')
# Notification assigned_to
assert notifications[0].user == member2.user
assert notifications[0].event_type == WebNotificationType.assigned.value
assert notifications[0].read is None
assert notifications[0].data == notification_data
# Notification added_as_watcher
assert notifications[1].user == member3.user
assert notifications[1].event_type == WebNotificationType.added_as_watcher
assert notifications[1].read is None
assert notifications[1].data == notification_data
# Notification mentioned
assert notifications[2].user == member4.user
assert notifications[2].event_type == WebNotificationType.mentioned
assert notifications[2].read is None
assert notifications[2].data == notification_data
def test_comment_on_issue_generates_web_notifications(client):
project = f.ProjectFactory.create()
role = f.RoleFactory.create(
project=project,
permissions=['view_issues', 'modify_issue']
)
member1 = f.MembershipFactory.create(project=project, role=role)
member2 = f.MembershipFactory.create(project=project, role=role)
issue = f.IssueFactory.create(project=project, owner=member1.user)
issue.add_watcher(member2.user)
client.login(member1.user)
mock_path = "taiga.projects.issues.api.IssueViewSet.pre_conditions_on_save"
with patch(mock_path):
client.patch(
reverse("issues-detail", args=[issue.pk]),
json.dumps({
"version": issue.version,
"comment": "Lorem ipsum dolor sit amet",
}),
content_type="application/json"
)
assert 1 == models.WebNotification.objects.count()
notification = models.WebNotification.objects.first()
notification_data = _notification_data(project, member1.user, issue,
'issue')
# Notification comment
assert notification.user == member2.user
assert notification.event_type == WebNotificationType.comment
assert notification.read is None
assert notification.data == notification_data
def test_task_updated_generates_web_notifications(client):
project = f.ProjectFactory.create()
role = f.RoleFactory.create(
project=project,
permissions=['view_tasks', 'modify_task']
)
member1 = f.MembershipFactory.create(project=project, role=role)
member2 = f.MembershipFactory.create(project=project, role=role)
member3 = f.MembershipFactory.create(project=project, role=role)
member4 = f.MembershipFactory.create(project=project, role=role)
task = f.TaskFactory.create(project=project, owner=member1.user)
client.login(member1.user)
mock_path = "taiga.projects.tasks.api.TaskViewSet.pre_conditions_on_save"
with patch(mock_path):
client.patch(
reverse("tasks-detail", args=[task.pk]),
json.dumps({
"description": "Lorem ipsum @%s dolor sit amet" %
member4.user.username,
"assigned_to": member2.user.pk,
"watchers": [member3.user.pk],
"version": task.version
}),
content_type="application/json"
)
assert 3 == models.WebNotification.objects.count()
notifications = models.WebNotification.objects.all()
notification_data = _notification_data(project, member1.user, task, 'task')
# Notification assigned_to
assert notifications[0].user == member2.user
assert notifications[0].event_type == WebNotificationType.assigned.value
assert notifications[0].read is None
assert notifications[0].data == notification_data
# Notification added_as_watcher
assert notifications[1].user == member3.user
assert notifications[1].event_type == WebNotificationType.added_as_watcher
assert notifications[1].read is None
assert notifications[1].data == notification_data
# Notification mentioned
assert notifications[2].user == member4.user
assert notifications[2].event_type == WebNotificationType.mentioned
assert notifications[2].read is None
assert notifications[2].data == notification_data
def test_comment_on_task_generates_web_notifications(client):
project = f.ProjectFactory.create()
role = f.RoleFactory.create(
project=project,
permissions=['view_tasks', 'modify_task']
)
member1 = f.MembershipFactory.create(project=project, role=role)
member2 = f.MembershipFactory.create(project=project, role=role)
task = f.TaskFactory.create(project=project, owner=member1.user)
task.add_watcher(member2.user)
client.login(member1.user)
mock_path = "taiga.projects.tasks.api.TaskViewSet.pre_conditions_on_save"
with patch(mock_path):
client.patch(
reverse("tasks-detail", args=[task.pk]),
json.dumps({
"version": task.version,
"comment": "Lorem ipsum dolor sit amet",
}),
content_type="application/json"
)
assert 1 == models.WebNotification.objects.count()
notification = models.WebNotification.objects.first()
notification_data = _notification_data(project, member1.user, task, 'task')
# Notification comment
assert notification.user == member2.user
assert notification.event_type == WebNotificationType.comment
assert notification.read is None
assert notification.data == notification_data
def test_us_updated_generates_web_notifications(client):
project = f.ProjectFactory.create()
role = f.RoleFactory.create(
project=project,
permissions=['view_us', 'modify_us']
)
member1 = f.MembershipFactory.create(project=project, role=role)
member2 = f.MembershipFactory.create(project=project, role=role)
member3 = f.MembershipFactory.create(project=project, role=role)
member4 = f.MembershipFactory.create(project=project, role=role)
us = f.UserStoryFactory.create(project=project,
owner=member1.user,
milestone=None)
client.login(member1.user)
mock_path = "taiga.projects.userstories.api.UserStoryViewSet." \
"pre_conditions_on_save"
with patch(mock_path):
client.patch(
reverse("userstories-detail", args=[us.pk]),
json.dumps({
"description": "Lorem ipsum @%s dolor sit amet" %
member4.user.username,
"assigned_users": [member2.user.pk],
"watchers": [member3.user.pk],
"version": us.version
}),
content_type="application/json"
)
assert 3 == models.WebNotification.objects.count()
notifications = models.WebNotification.objects.all()
notification_data = _notification_data(project, member1.user, us,
'userstory')
# Notification added_as_watcher
assert notifications[0].user == member3.user
assert notifications[0].event_type == WebNotificationType.added_as_watcher
assert notifications[0].read is None
assert notifications[0].data == notification_data
# Notification mentioned
assert notifications[1].user == member4.user
assert notifications[1].event_type == WebNotificationType.mentioned
assert notifications[1].read is None
assert notifications[1].data == notification_data
# Notification assigned_users
assert notifications[2].user == member2.user
assert notifications[2].event_type == WebNotificationType.assigned.value
assert notifications[2].read is None
assert notifications[2].data == notification_data
def test_comment_on_us_generates_web_notifications(client):
project = f.ProjectFactory.create()
role = f.RoleFactory.create(
project=project,
permissions=['view_us', 'modify_us']
)
member1 = f.MembershipFactory.create(project=project, role=role)
member2 = f.MembershipFactory.create(project=project, role=role)
us = f.UserStoryFactory.create(project=project,
owner=member1.user,
milestone=None)
us.add_watcher(member2.user)
client.login(member1.user)
mock_path = "taiga.projects.userstories.api.UserStoryViewSet." \
"pre_conditions_on_save"
with patch(mock_path):
client.patch(
reverse("userstories-detail", args=[us.pk]),
json.dumps({
"version": us.version,
"comment": "Lorem ipsum dolor sit amet",
}),
content_type="application/json"
)
assert 1 == models.WebNotification.objects.count()
notification = models.WebNotification.objects.first()
notification_data = _notification_data(project, member1.user, us,
'userstory')
# Notification comment
assert notification.user == member2.user
assert notification.event_type == WebNotificationType.comment
assert notification.read is None
assert notification.data == notification_data
def test_new_member_generates_web_notifications(client):
project = f.ProjectFactory()
john = f.UserFactory.create()
joseph = f.UserFactory.create()
other = f.UserFactory.create()
tester = f.RoleFactory(project=project, name="Tester",
permissions=["view_project"])
gamer = f.RoleFactory(project=project, name="Gamer",
permissions=["view_project"])
f.MembershipFactory(project=project, user=john, role=tester, is_admin=True)
# John and Other are members from another project
project2 = f.ProjectFactory()
f.MembershipFactory(project=project2, user=john, role=gamer, is_admin=True)
f.MembershipFactory(project=project2, user=other, role=gamer)
url = reverse("memberships-bulk-create")
data = {
"project_id": project.id,
"bulk_memberships": [
{"role_id": gamer.pk, "username": joseph.email},
{"role_id": gamer.pk, "username": other.username},
]
}
client.login(john)
client.json.post(url, json.dumps(data))
assert models.WebNotification.objects.count() == 2
notifications = models.WebNotification.objects.all()
# Notification added_as_member
assert notifications[0].user == joseph
assert notifications[0].event_type == WebNotificationType.added_as_member
assert notifications[0].read is None
# Notification added_as_member
assert notifications[1].user == other
assert notifications[1].event_type == WebNotificationType.added_as_member
assert notifications[1].read is None

View File

@ -0,0 +1,94 @@
import json
import pytest
from django.apps import apps
from django.core.urlresolvers import reverse
from .. import factories as f
from taiga.projects.settings import services
from taiga.projects.settings.choices import Section
pytestmark = pytest.mark.django_db
def test_home_page_setting_existence():
project = f.ProjectFactory.create()
assert not services.user_project_settings_exists(project, project.owner)
services.create_user_project_settings(project, project.owner, Section.kanban)
assert services.user_project_settings_exists(project, project.owner)
def test_create_retrieve_home_page_setting():
project = f.ProjectFactory.create()
policy_model_cls = apps.get_model("settings", "UserProjectSettings")
current_number = policy_model_cls.objects.all().count()
assert current_number == 0
setting = services.create_user_project_settings_if_not_exists(project,
project.owner)
current_number = policy_model_cls.objects.all().count()
assert current_number == 1
assert setting.homepage == Section.timeline
def test_retrieve_homepage_setting_with_allowed_sections(client):
# Default template has next configuration:
# "is_epics_activated": false,
# "is_backlog_activated": true,
# "is_kanban_activated": false,
# "is_wiki_activated": true,
# "is_issues_activated": true,
user = f.UserFactory.create()
project = f.ProjectFactory.create(owner=user)
membership = f.MembershipFactory.create(user=user, project=project, is_admin=False)
membership.role.permissions = ["view_us", "view_wiki_pages"]
membership.role.save()
url = reverse("user-project-settings-list")
client.login(project.owner)
response = client.get(url)
assert response.status_code == 200
assert 1 == len(response.data)
assert 1 == response.data[0].get("homepage")
assert 3 == len(response.data[0].get("allowed_sections"))
assert Section.timeline in response.data[0].get("allowed_sections")
assert Section.backlog in response.data[0].get("allowed_sections")
assert Section.wiki in response.data[0].get("allowed_sections")
assert Section.epics not in response.data[0].get("allowed_sections")
assert Section.issues not in response.data[0].get("allowed_sections")
def test_avoid_patch_homepage_setting_with_not_allowed_section(client):
# Default template has next configuration:
# "is_epics_activated": false,
# "is_backlog_activated": true,
# "is_kanban_activated": false,
# "is_wiki_activated": true,
# "is_issues_activated": true,
user = f.UserFactory.create()
project = f.ProjectFactory.create(owner=user)
membership = f.MembershipFactory.create(user=user, project=project,
is_admin=False)
membership.role.permissions = ["view_us", "view_wiki_pages"]
membership.role.save()
setting = services.create_user_project_settings_if_not_exists(project,
project.owner)
url = reverse("user-project-settings-detail", args=[setting.pk])
client.login(project.owner)
response = client.json.patch(url, data=json.dumps({"homepage": Section.backlog}))
assert response.status_code == 200
response = client.json.patch(url, data=json.dumps({"homepage": Section.issues}))
assert response.status_code == 400

View File

@ -39,6 +39,62 @@ import pytest
pytestmark = pytest.mark.django_db
def create_tasks_fixtures():
data = {}
data["project"] = f.ProjectFactory.create()
project = data["project"]
data["users"] = [f.UserFactory.create(is_superuser=True) for i in range(0, 3)]
data["roles"] = [f.RoleFactory.create() for i in range(0, 3)]
user_roles = zip(data["users"], data["roles"])
# Add membership fixtures
[f.MembershipFactory.create(user=user, project=project, role=role) for (user, role) in user_roles]
data["statuses"] = [f.TaskStatusFactory.create(project=project) for i in range(0, 4)]
data["tags"] = ["test1test2test3", "test1", "test2", "test3"]
# ----------------------------------------------------------------
# | Task | Owner | Assigned To | Tags | Status |
# |-------#--------#-------------#---------------------|---------|
# | 0 | user2 | None | tag1 | status3 |
# | 1 | user1 | None | tag2 | status3 |
# | 2 | user3 | None | tag1 tag2 | status1 |
# | 3 | user2 | None | tag3 | status0 |
# | 4 | user1 | user1 | tag1 tag2 tag3 | status0 |
# | 5 | user3 | user1 | tag3 | status2 |
# | 6 | user2 | user1 | tag1 tag2 | status3 |
# | 7 | user1 | user2 | tag3 | status0 |
# | 8 | user3 | user2 | tag1 | status3 |
# | 9 | user2 | user3 | tag0 | status1 |
# ----------------------------------------------------------------
(user1, user2, user3, ) = data["users"]
(status0, status1, status2, status3 ) = data["statuses"]
(tag0, tag1, tag2, tag3, ) = data["tags"]
f.TaskFactory.create(project=project, owner=user2, assigned_to=None,
status=status3, tags=[tag1])
f.TaskFactory.create(project=project, owner=user1, assigned_to=None,
status=status3, tags=[tag2])
f.TaskFactory.create(project=project, owner=user3, assigned_to=None,
status=status1, tags=[tag1, tag2])
f.TaskFactory.create(project=project, owner=user2, assigned_to=None,
status=status0, tags=[tag3])
f.TaskFactory.create(project=project, owner=user1, assigned_to=user1,
status=status0, tags=[tag1, tag2, tag3])
f.TaskFactory.create(project=project, owner=user3, assigned_to=user1,
status=status2, tags=[tag3])
f.TaskFactory.create(project=project, owner=user2, assigned_to=user1,
status=status3, tags=[tag1, tag2])
f.TaskFactory.create(project=project, owner=user1, assigned_to=user2,
status=status0, tags=[tag3])
f.TaskFactory.create(project=project, owner=user3, assigned_to=user2,
status=status3, tags=[tag1])
f.TaskFactory.create(project=project, owner=user2, assigned_to=user3,
status=status1, tags=[tag0])
return data
def test_get_tasks_from_bulk():
data = """
Task #1
@ -544,6 +600,66 @@ def test_api_update_order_in_bulk_invalid_user_story_2(client):
assert "bulk_tasks" in response.data
def test_api_update_milestone_in_bulk(client):
user = f.UserFactory.create()
project = f.ProjectFactory.create(owner=user, default_task_status=None)
f.MembershipFactory.create(project=project, user=user, is_admin=True)
milestone1 = f.MilestoneFactory(project=project)
milestone2 = f.MilestoneFactory(project=project)
task1 = f.create_task(project=project, milestone=milestone1)
task2 = f.create_task(project=project, milestone=milestone1)
task3 = f.create_task(project=project, milestone=milestone1)
url = reverse("tasks-bulk-update-milestone")
data = {
"project_id": project.id,
"milestone_id": milestone2.id,
"bulk_tasks": [{"task_id": task1.id, "order": 1},
{"task_id": task2.id, "order": 2},
{"task_id": task3.id, "order": 3}]
}
client.login(project.owner)
response = client.json.post(url, json.dumps(data))
assert response.status_code == 200, response.data
assert response.data[task1.id] == milestone2.id
assert response.data[task2.id] == milestone2.id
assert response.data[task3.id] == milestone2.id
def test_api_update_milestone_in_bulk_invalid_milestone(client):
user = f.UserFactory.create()
project = f.ProjectFactory.create(owner=user, default_task_status=None)
f.MembershipFactory.create(project=project, user=user, is_admin=True)
milestone1 = f.MilestoneFactory(project=project)
milestone2 = f.MilestoneFactory()
task1 = f.create_task(project=project, milestone=milestone1)
task2 = f.create_task(project=project, milestone=milestone1)
task3 = f.create_task(project=project, milestone=milestone1)
url = reverse("tasks-bulk-update-milestone")
data = {
"project_id": project.id,
"milestone_id": milestone2.id,
"bulk_tasks": [{"task_id": task1.id, "order": 1},
{"task_id": task2.id, "order": 2},
{"task_id": task3.id, "order": 3}]
}
client.login(project.owner)
response = client.json.post(url, json.dumps(data))
assert response.status_code == 400
assert "milestone_id" in response.data
def test_get_invalid_csv(client):
url = reverse("tasks-csv")
@ -736,63 +852,45 @@ def test_api_filter_by_milestone__estimated_start_and_end(client, field_name):
assert response.data[0]["subject"] == task.subject
@pytest.mark.parametrize("filter_name,collection,expected,exclude_expected,is_text", [
('status', 'statuses', 3, 7, False),
('assigned_to', 'users', 3, 7, False),
('tags', 'tags', 1, 9, True),
('owner', 'users', 3, 7, False),
('role', 'roles', 3, 7, False),
])
def test_api_filters(client, filter_name, collection, expected, exclude_expected, is_text):
data = create_tasks_fixtures()
project = data["project"]
options = data[collection]
client.login(data["users"][0])
if is_text:
param = options[0]
else:
param = options[0].id
# include test
url = "{}?project={}&{}={}".format(reverse('tasks-list'), project.id, filter_name, param)
response = client.get(url)
assert response.status_code == 200
assert len(response.data) == expected
# exclude test
url = "{}?project={}&exclude_{}={}".format(reverse('tasks-list'), project.id, filter_name, param)
response = client.get(url)
assert response.status_code == 200
assert len(response.data) == exclude_expected
def test_api_filters_data(client):
project = f.ProjectFactory.create()
user1 = f.UserFactory.create(is_superuser=True)
f.MembershipFactory.create(user=user1, project=project)
user2 = f.UserFactory.create(is_superuser=True)
f.MembershipFactory.create(user=user2, project=project)
user3 = f.UserFactory.create(is_superuser=True)
f.MembershipFactory.create(user=user3, project=project)
status0 = f.TaskStatusFactory.create(project=project)
status1 = f.TaskStatusFactory.create(project=project)
status2 = f.TaskStatusFactory.create(project=project)
status3 = f.TaskStatusFactory.create(project=project)
tag0 = "test1test2test3"
tag1 = "test1"
tag2 = "test2"
tag3 = "test3"
# ------------------------------------------------------
# | Task | Owner | Assigned To | Tags |
# |-------#--------#-------------#---------------------|
# | 0 | user2 | None | tag1 |
# | 1 | user1 | None | tag2 |
# | 2 | user3 | None | tag1 tag2 |
# | 3 | user2 | None | tag3 |
# | 4 | user1 | user1 | tag1 tag2 tag3 |
# | 5 | user3 | user1 | tag3 |
# | 6 | user2 | user1 | tag1 tag2 |
# | 7 | user1 | user2 | tag3 |
# | 8 | user3 | user2 | tag1 |
# | 9 | user2 | user3 | tag0 |
# ------------------------------------------------------
task0 = f.TaskFactory.create(project=project, owner=user2, assigned_to=None,
status=status3, tags=[tag1])
task1 = f.TaskFactory.create(project=project, owner=user1, assigned_to=None,
status=status3, tags=[tag2])
task2 = f.TaskFactory.create(project=project, owner=user3, assigned_to=None,
status=status1, tags=[tag1, tag2])
task3 = f.TaskFactory.create(project=project, owner=user2, assigned_to=None,
status=status0, tags=[tag3])
task4 = f.TaskFactory.create(project=project, owner=user1, assigned_to=user1,
status=status0, tags=[tag1, tag2, tag3])
task5 = f.TaskFactory.create(project=project, owner=user3, assigned_to=user1,
status=status2, tags=[tag3])
task6 = f.TaskFactory.create(project=project, owner=user2, assigned_to=user1,
status=status3, tags=[tag1, tag2])
task7 = f.TaskFactory.create(project=project, owner=user1, assigned_to=user2,
status=status0, tags=[tag3])
task8 = f.TaskFactory.create(project=project, owner=user3, assigned_to=user2,
status=status3, tags=[tag1])
task9 = f.TaskFactory.create(project=project, owner=user2, assigned_to=user3,
status=status1, tags=[tag0])
data = create_tasks_fixtures()
project = data["project"]
(user1, user2, user3, ) = data["users"]
(status0, status1, status2, status3, ) = data["statuses"]
(tag0, tag1, tag2, tag3, ) = data["tags"]
url = reverse("tasks-filters-data") + "?project={}".format(project.id)
client.login(user1)
## No filter

View File

@ -38,6 +38,71 @@ import pytest
pytestmark = pytest.mark.django_db(transaction=True)
def create_uss_fixtures():
data = {}
data["project"] = f.ProjectFactory.create()
project = data["project"]
data["users"] = [f.UserFactory.create(is_superuser=True) for i in range(0, 3)]
data["roles"] = [f.RoleFactory.create() for i in range(0, 3)]
user_roles = zip(data["users"], data["roles"])
# Add membership fixtures
[f.MembershipFactory.create(user=user, project=project, role=role) for (user, role) in user_roles]
data["statuses"] = [f.UserStoryStatusFactory.create(project=project) for i in range(0, 4)]
data["epics"] = [f.EpicFactory.create(project=project) for i in range(0, 3)]
data["tags"] = ["test1test2test3", "test1", "test2", "test3"]
# ----------------------------------------------------------------------------------------------------
# | US | Status | Owner | Assigned To | Assigned Users | Tags | Epic |
# |-------#---------#--------#-------------#---------------------#---------------------#--------------
# | 0 | status3 | user2 | None | None | tag1 | epic0 |
# | 1 | status3 | user1 | None | user1 | tag2 | None |
# | 2 | status1 | user3 | None | None | tag1 tag2 | epic1 |
# | 3 | status0 | user2 | None | None | tag3 | None |
# | 4 | status0 | user1 | user1 | None | tag1 tag2 tag3 | epic0 |
# | 5 | status2 | user3 | user1 | None | tag3 | None |
# | 6 | status3 | user2 | user1 | None | tag1 tag2 | epic0 epic2 |
# | 7 | status0 | user1 | user2 | None | tag3 | None |
# | 8 | status3 | user3 | user2 | None | tag1 | epic2 |
# | 9 | status1 | user2 | user3 | user1 | tag0 | None |
# ----------------------------------------------------------------------------------------------------
(user1, user2, user3, ) = data["users"]
(status0, status1, status2, status3 ) = data["statuses"]
(epic0, epic1, epic2) = data["epics"]
(tag0, tag1, tag2, tag3, ) = data["tags"]
us0 = f.UserStoryFactory.create(project=project, owner=user2, assigned_to=None,
status=status3, tags=[tag1])
f.RelatedUserStory.create(user_story=us0, epic=epic0)
us1 = f.UserStoryFactory.create(project=project, owner=user1, assigned_to=None,
status=status3, tags=[tag2], assigned_users=[user1])
us2 = f.UserStoryFactory.create(project=project, owner=user3, assigned_to=None,
status=status1, tags=[tag1, tag2])
f.RelatedUserStory.create(user_story=us2, epic=epic1)
us3 = f.UserStoryFactory.create(project=project, owner=user2, assigned_to=None,
status=status0, tags=[tag3])
us4 = f.UserStoryFactory.create(project=project, owner=user1, assigned_to=user1,
status=status0, tags=[tag1, tag2, tag3])
f.RelatedUserStory.create(user_story=us4, epic=epic0)
us5 = f.UserStoryFactory.create(project=project, owner=user3, assigned_to=user1,
status=status2, tags=[tag3])
us6 = f.UserStoryFactory.create(project=project, owner=user2, assigned_to=user1,
status=status3, tags=[tag1, tag2])
f.RelatedUserStory.create(user_story=us6, epic=epic0)
f.RelatedUserStory.create(user_story=us6, epic=epic2)
us7 = f.UserStoryFactory.create(project=project, owner=user1, assigned_to=user2,
status=status0, tags=[tag3])
us8 = f.UserStoryFactory.create(project=project, owner=user3, assigned_to=user2,
status=status3, tags=[tag1])
f.RelatedUserStory.create(user_story=us8, epic=epic2)
us9 = f.UserStoryFactory.create(project=project, owner=user2, assigned_to=user3,
status=status1, tags=[tag0], assigned_users=[user1])
return data
def test_get_userstories_from_bulk():
data = "User Story #1\nUser Story #2\n"
userstories = services.get_userstories_from_bulk(data)
@ -777,72 +842,14 @@ def test_api_filter_by_milestone__estimated_start_and_end(client, field_name):
def test_api_filters_data(client):
project = f.ProjectFactory.create()
user1 = f.UserFactory.create(is_superuser=True)
f.MembershipFactory.create(user=user1, project=project)
user2 = f.UserFactory.create(is_superuser=True)
f.MembershipFactory.create(user=user2, project=project)
user3 = f.UserFactory.create(is_superuser=True)
f.MembershipFactory.create(user=user3, project=project)
status0 = f.UserStoryStatusFactory.create(project=project)
status1 = f.UserStoryStatusFactory.create(project=project)
status2 = f.UserStoryStatusFactory.create(project=project)
status3 = f.UserStoryStatusFactory.create(project=project)
epic0 = f.EpicFactory.create(project=project)
epic1 = f.EpicFactory.create(project=project)
epic2 = f.EpicFactory.create(project=project)
tag0 = "test1test2test3"
tag1 = "test1"
tag2 = "test2"
tag3 = "test3"
# ----------------------------------------------------------------------------------------------------
# | US | Status | Owner | Assigned To | Assigned Users | Tags | Epic |
# |-------#---------#--------#-------------#---------------------#---------------------#--------------
# | 0 | status3 | user2 | None | None | tag1 | epic0 |
# | 1 | status3 | user1 | None | user1 | tag2 | None |
# | 2 | status1 | user3 | None | None | tag1 tag2 | epic1 |
# | 3 | status0 | user2 | None | None | tag3 | None |
# | 4 | status0 | user1 | user1 | None | tag1 tag2 tag3 | epic0 |
# | 5 | status2 | user3 | user1 | None | tag3 | None |
# | 6 | status3 | user2 | user1 | None | tag1 tag2 | epic0 epic2 |
# | 7 | status0 | user1 | user2 | None | tag3 | None |
# | 8 | status3 | user3 | user2 | None | tag1 | epic2 |
# | 9 | status1 | user2 | user3 | user1 | tag0 | None |
# ----------------------------------------------------------------------------------------------------
us0 = f.UserStoryFactory.create(project=project, owner=user2, assigned_to=None,
status=status3, tags=[tag1])
f.RelatedUserStory.create(user_story=us0, epic=epic0)
us1 = f.UserStoryFactory.create(project=project, owner=user1, assigned_to=None,
status=status3, tags=[tag2], assigned_users=[user1])
us2 = f.UserStoryFactory.create(project=project, owner=user3, assigned_to=None,
status=status1, tags=[tag1, tag2])
f.RelatedUserStory.create(user_story=us2, epic=epic1)
us3 = f.UserStoryFactory.create(project=project, owner=user2, assigned_to=None,
status=status0, tags=[tag3])
us4 = f.UserStoryFactory.create(project=project, owner=user1, assigned_to=user1,
status=status0, tags=[tag1, tag2, tag3])
f.RelatedUserStory.create(user_story=us4, epic=epic0)
us5 = f.UserStoryFactory.create(project=project, owner=user3, assigned_to=user1,
status=status2, tags=[tag3])
us6 = f.UserStoryFactory.create(project=project, owner=user2, assigned_to=user1,
status=status3, tags=[tag1, tag2])
f.RelatedUserStory.create(user_story=us6, epic=epic0)
f.RelatedUserStory.create(user_story=us6, epic=epic2)
us7 = f.UserStoryFactory.create(project=project, owner=user1, assigned_to=user2,
status=status0, tags=[tag3])
us8 = f.UserStoryFactory.create(project=project, owner=user3, assigned_to=user2,
status=status3, tags=[tag1])
f.RelatedUserStory.create(user_story=us8, epic=epic2)
us9 = f.UserStoryFactory.create(project=project, owner=user2, assigned_to=user3,
status=status1, tags=[tag0], assigned_users=[user1])
data = create_uss_fixtures()
project = data["project"]
(user1, user2, user3, ) = data["users"]
(status0, status1, status2, status3, ) = data["statuses"]
(epic0, epic1, epic2, ) = data["epics"]
(tag0, tag1, tag2, tag3, ) = data["tags"]
url = reverse("userstories-filters-data") + "?project={}".format(project.id)
client.login(user1)
# No filter
@ -961,6 +968,37 @@ def test_api_filters_data(client):
assert next(filter(lambda i: i['id'] == epic2.id, response.data["epics"]))["count"] == 2
@pytest.mark.parametrize("filter_name,collection,expected,exclude_expected,is_text", [
('status', 'statuses', 3, 7, False),
('tags', 'tags', 1, 9, True),
('owner', 'users', 3, 7, False),
('role', 'roles', 5, 5, False),
('assigned_users', 'users', 5, 5, False),
])
def test_api_filters(client, filter_name, collection, expected, exclude_expected, is_text):
data = create_uss_fixtures()
project = data["project"]
options = data[collection]
client.login(data["users"][0])
if is_text:
param = options[0]
else:
param = options[0].id
# include test
url = "{}?project={}&{}={}".format(reverse('userstories-list'), project.id, filter_name, param)
response = client.get(url)
assert response.status_code == 200
assert len(response.data) == expected
# exclude test
url = "{}?project={}&exclude_{}={}".format(reverse('userstories-list'), project.id, filter_name, param)
response = client.get(url)
assert response.status_code == 200
assert len(response.data) == exclude_expected
def test_api_filters_data_with_assigned_users(client):
project = f.ProjectFactory.create()
user1 = f.UserFactory.create(is_superuser=True)

View File

@ -85,7 +85,7 @@ def test_proccessor_valid_us_reference():
instance.content_type.model = "userstory"
instance.content_object.subject = "test"
result = render(dummy_project, "**#1**")
expected_result = '<p><strong><a class="reference user-story" href="http://localhost:9001/project/test/us/1" title="#1 test">#1</a></strong></p>'
expected_result = '<p><strong><a class="reference user-story" href="http://localhost:9001/project/test/us/1" title="#1 test">&num;1</a></strong></p>'
assert result == expected_result
@ -95,7 +95,7 @@ def test_proccessor_valid_issue_reference():
instance.content_type.model = "issue"
instance.content_object.subject = "test"
result = render(dummy_project, "**#2**")
expected_result = '<p><strong><a class="reference issue" href="http://localhost:9001/project/test/issue/2" title="#2 test">#2</a></strong></p>'
expected_result = '<p><strong><a class="reference issue" href="http://localhost:9001/project/test/issue/2" title="#2 test">&num;2</a></strong></p>'
assert result == expected_result
@ -105,7 +105,7 @@ def test_proccessor_valid_task_reference():
instance.content_type.model = "task"
instance.content_object.subject = "test"
result = render(dummy_project, "**#3**")
expected_result = '<p><strong><a class="reference task" href="http://localhost:9001/project/test/task/3" title="#3 test">#3</a></strong></p>'
expected_result = '<p><strong><a class="reference task" href="http://localhost:9001/project/test/task/3" title="#3 test">&num;3</a></strong></p>'
assert result == expected_result
@ -207,13 +207,13 @@ def test_render_relative_image():
def test_render_triple_quote_code():
expected_result = '<div class="codehilite"><pre><span></span><span class="k">print</span><span class="p">(</span><span class="s2">"test"</span><span class="p">)</span>\n</pre></div>'
expected_result = '<div class="codehilite"><pre><span></span><span class="k">print</span><span class="p">(</span><span class="s2">&quot;test&quot;</span><span class="p">)</span>\n</pre></div>'
assert render(dummy_project, "```python\nprint(\"test\")\n```") == expected_result
def test_render_triple_quote_and_lang_code():
expected_result = '<div class="codehilite"><pre><span></span><span class="k">print</span><span class="p">(</span><span class="s2">"test"</span><span class="p">)</span>\n</pre></div>'
expected_result = '<div class="codehilite"><pre><span></span><span class="k">print</span><span class="p">(</span><span class="s2">&quot;test&quot;</span><span class="p">)</span>\n</pre></div>'
assert render(dummy_project, "```python\nprint(\"test\")\n```") == expected_result

View File

@ -0,0 +1,268 @@
import pytest
from .. import factories as f
from taiga.projects.milestones import services
pytestmark = pytest.mark.django_db
def test_issues_not_closed():
project = f.ProjectFactory()
f.MembershipFactory.create(project=project, user=project.owner,
is_admin=True)
milestone1 = f.MilestoneFactory.create(project=project)
closed_status = f.IssueStatusFactory.create(project=project,
is_closed=True)
f.create_issue(project=project, milestone=milestone1,
status=closed_status)
f.create_issue(project=project, milestone=milestone1)
assert not services.calculate_milestone_is_closed(milestone1)
def test_issues_closed():
project = f.ProjectFactory()
f.MembershipFactory.create(project=project, user=project.owner,
is_admin=True)
milestone1 = f.MilestoneFactory.create(project=project)
closed_status = f.IssueStatusFactory.create(project=project,
is_closed=True)
f.create_issue(project=project, milestone=milestone1,
status=closed_status)
assert services.calculate_milestone_is_closed(milestone1)
def test_stay_open_with_issues_but_closed_tasks():
project = f.ProjectFactory()
f.MembershipFactory.create(project=project, user=project.owner,
is_admin=True)
milestone1 = f.MilestoneFactory.create(project=project)
tasks_closed_status = f.TaskStatusFactory.create(project=project,
is_closed=True)
f.create_task(project=project, milestone=milestone1,
taskboard_order=1, status=tasks_closed_status)
f.create_issue(project=project, milestone=milestone1)
assert not services.calculate_milestone_is_closed(milestone1)
def test_stay_open_with_issues_but_closed_uss():
project = f.ProjectFactory()
f.MembershipFactory.create(project=project, user=project.owner,
is_admin=True)
milestone1 = f.MilestoneFactory.create(project=project)
us_closed_status = f.UserStoryStatusFactory.create(project=project,
is_closed=True)
f.create_userstory(project=project, milestone=milestone1,
status=us_closed_status, is_closed=True)
f.create_issue(project=project, milestone=milestone1)
assert not services.calculate_milestone_is_closed(milestone1)
def test_stay_open_with_closed_issues_but_open_uss():
project = f.ProjectFactory()
f.MembershipFactory.create(project=project, user=project.owner,
is_admin=True)
milestone1 = f.MilestoneFactory.create(project=project)
closed_status = f.IssueStatusFactory.create(project=project,
is_closed=True)
f.create_issue(project=project, milestone=milestone1,
status=closed_status)
f.create_userstory(project=project, milestone=milestone1)
assert not services.calculate_milestone_is_closed(milestone1)
def test_stay_open_with_closed_issues_but_open_tasks():
project = f.ProjectFactory()
f.MembershipFactory.create(project=project, user=project.owner,
is_admin=True)
milestone1 = f.MilestoneFactory.create(project=project)
closed_status = f.IssueStatusFactory.create(project=project,
is_closed=True)
f.create_issue(project=project, milestone=milestone1,
status=closed_status)
f.create_task(project=project, milestone=milestone1)
assert not services.calculate_milestone_is_closed(milestone1)
def test_tasks_not_closed():
project = f.ProjectFactory()
f.MembershipFactory.create(project=project, user=project.owner,
is_admin=True)
milestone1 = f.MilestoneFactory.create(project=project)
closed_status = f.TaskStatusFactory.create(project=project,
is_closed=True)
f.create_task(project=project, milestone=milestone1,
status=closed_status)
f.create_task(project=project, milestone=milestone1)
assert not services.calculate_milestone_is_closed(milestone1)
def test_tasks_closed():
project = f.ProjectFactory()
f.MembershipFactory.create(project=project, user=project.owner,
is_admin=True)
milestone1 = f.MilestoneFactory.create(project=project)
closed_status = f.TaskStatusFactory.create(project=project,
is_closed=True)
f.create_task(project=project, milestone=milestone1,
status=closed_status, user_story=None)
assert services.calculate_milestone_is_closed(milestone1)
def test_stay_open_with_tasks_but_closed_issues():
project = f.ProjectFactory()
f.MembershipFactory.create(project=project, user=project.owner,
is_admin=True)
milestone1 = f.MilestoneFactory.create(project=project)
issue_closed_status = f.IssueStatusFactory.create(project=project,
is_closed=True)
f.create_issue(project=project, milestone=milestone1,
status=issue_closed_status)
f.create_task(project=project, milestone=milestone1)
assert not services.calculate_milestone_is_closed(milestone1)
def test_stay_open_with_tasks_but_closed_uss():
project = f.ProjectFactory()
f.MembershipFactory.create(project=project, user=project.owner,
is_admin=True)
milestone1 = f.MilestoneFactory.create(project=project)
us_closed_status = f.UserStoryStatusFactory.create(project=project,
is_closed=True)
f.create_userstory(project=project, milestone=milestone1,
status=us_closed_status, is_closed=True)
f.create_task(project=project, milestone=milestone1, user_story=None)
assert not services.calculate_milestone_is_closed(milestone1)
def test_stay_open_with_closed_tasks_but_open_uss():
project = f.ProjectFactory()
f.MembershipFactory.create(project=project, user=project.owner,
is_admin=True)
milestone1 = f.MilestoneFactory.create(project=project)
closed_status = f.TaskStatusFactory.create(project=project,
is_closed=True)
f.create_task(project=project, milestone=milestone1,
status=closed_status, user_story=None)
f.create_userstory(project=project, milestone=milestone1)
assert not services.calculate_milestone_is_closed(milestone1)
def test_stay_open_with_closed_tasks_but_open_issues():
project = f.ProjectFactory()
f.MembershipFactory.create(project=project, user=project.owner,
is_admin=True)
milestone1 = f.MilestoneFactory.create(project=project)
closed_status = f.TaskStatusFactory.create(project=project,
is_closed=True)
f.create_task(project=project, milestone=milestone1,
status=closed_status, user_story=None)
f.create_issue(project=project, milestone=milestone1)
assert not services.calculate_milestone_is_closed(milestone1)
def test_uss_not_closed():
project = f.ProjectFactory()
f.MembershipFactory.create(project=project, user=project.owner,
is_admin=True)
milestone1 = f.MilestoneFactory.create(project=project)
closed_status = f.UserStoryStatusFactory.create(project=project,
is_closed=True)
f.create_userstory(project=project, milestone=milestone1,
status=closed_status, is_closed=True)
f.create_userstory(project=project, milestone=milestone1)
assert not services.calculate_milestone_is_closed(milestone1)
def test_uss_closed():
project = f.ProjectFactory()
f.MembershipFactory.create(project=project, user=project.owner,
is_admin=True)
milestone1 = f.MilestoneFactory.create(project=project)
closed_status = f.UserStoryStatusFactory.create(project=project,
is_closed=True)
f.create_userstory(project=project, milestone=milestone1,
sprint_order=1, status=closed_status,
is_closed=True)
assert services.calculate_milestone_is_closed(milestone1)
def test_stay_open_with_uss_but_closed_tasks_and_us():
project = f.ProjectFactory()
f.MembershipFactory.create(project=project, user=project.owner,
is_admin=True)
milestone1 = f.MilestoneFactory.create(project=project)
us_closed_status = f.UserStoryStatusFactory.create(project=project,
is_closed=True)
us = f.create_userstory(project=project, milestone=milestone1,
status=us_closed_status, is_closed=True)
task_closed_status = f.TaskStatusFactory.create(project=project,
is_closed=True)
f.create_task(project=project, milestone=milestone1, user_story=us,
status=task_closed_status)
f.create_userstory(project=project, milestone=milestone1)
assert not services.calculate_milestone_is_closed(milestone1)
def test_stay_open_with_uss_but_closed_tasks():
project = f.ProjectFactory()
f.MembershipFactory.create(project=project, user=project.owner,
is_admin=True)
milestone1 = f.MilestoneFactory.create(project=project)
closed_status = f.TaskStatusFactory.create(project=project,
is_closed=True)
f.create_task(project=project, milestone=milestone1,
status=closed_status, user_story=None)
f.create_userstory(project=project, milestone=milestone1)
assert not services.calculate_milestone_is_closed(milestone1)
def test_stay_open_with_uss_but_closed_issues():
project = f.ProjectFactory()
f.MembershipFactory.create(project=project, user=project.owner,
is_admin=True)
milestone1 = f.MilestoneFactory.create(project=project)
closed_status = f.IssueStatusFactory.create(project=project,
is_closed=True)
f.create_issue(project=project, milestone=milestone1,
status=closed_status)
f.create_userstory(project=project, milestone=milestone1)
assert not services.calculate_milestone_is_closed(milestone1)

16
tox.ini Normal file
View File

@ -0,0 +1,16 @@
[tox]
envlist = py36
skipsdist = True
[testenv]
deps =
pipenv
commands =
pipenv sync --dev
pipenv run pytest
[testenv:flake8]
deps = flake8
basepython = python3.6
commands = flake8 .